From 9f939ca38a4a1b344c521fa0a3e0e16197295994 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Sat, 5 Sep 2026 01:36:17 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat!:=201.1=20=E2=80=94=20the=20v1=20surfa?= =?UTF-8?q?ce=20for=20six=20more=20resources,=20four=20new=20ones,=20and?= =?UTF-8?q?=20the=20wire=20renames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform published `/api/v1` for contacts, lists, templates, domains, webhooks and suppressions, added topics, snippets, bulk address validation and deliverability, and renamed several wire-visible fields. This release tracks all of it in one step, refreshes the vendored contract, and leaves NOT_YET_IMPLEMENTED empty: every key-callable operation in the spec now has a method. DO NOT PUBLISH until the platform deploy that ships the renamed wire has gone out. A 1.1 on npm before that deploy is a client that speaks a contract production does not yet answer. Breaking, all of it forced by the API: - `Template.type` / `Campaign.type` are `emailCategory` / `email_category`, and the enum member `HEADLESS` is `SELF_MANAGED_UNSUBSCRIBE`. - The custom-event body field `data` is `payload` on `events.record`. - `Domain.mailFromStatus` is `mailFromDomainStatus`. - `EmailGetResponse` splits into `EmailResponse` and `EmailDetailResponse`. - `emails.get` answers an explicit field list plus its DELIVERY events. It used to hand back the whole row — `bodyHash`, `dedupKey`, `idempotencyKey`, `linkMap` and both SES message ids included — and its `events` array was the custom analytics relation rather than the delivery history it always promised. `to` is now filled from the joined contact, which the spec declared and the response never carried. - `emails.cancelSchedule` resolves the email rather than an empty envelope, which is what the contract has published all along. - The double-opt-in confirmation route is `/api/lists/confirm-subscription`. Added: the v1 half of the six legacy-only resources, under a `V1` suffix so the two dialects cannot be confused at a call site; `topics`, `snippets`, `validation` and `deliverability` as new resources; campaign failure listing and retry; the workflow graph, clone, pause and resume; and mailbox message composition. Seventeen auto-pagination generators cover every cursor list. Two cursor dialects now exist and the contract guard knows both: most v1 lists take `after` and answer `next_cursor`, while topics and validation results take `cursor` and answer `cursor`. The `All` companions hide the difference; the resources that speak the second shape drive the page loop themselves, because the shared helper sends the wrong parameter and reads the wrong field. --- CHANGELOG.md | 231 + README.md | 677 +- dist/index.cjs | 1046 +- dist/index.d.cts | 15225 ++++++++++---- dist/index.d.ts | 15225 ++++++++++---- dist/index.js | 1042 +- openapi.json | 26904 +++++++++++++++++-------- package.json | 2 +- src/__tests__/campaigns.test.ts | 98 +- src/__tests__/contacts.test.ts | 133 +- src/__tests__/contract.test.ts | 142 +- src/__tests__/deliverability.test.ts | 147 + src/__tests__/domains.test.ts | 156 +- src/__tests__/events.test.ts | 8 +- src/__tests__/lists.test.ts | 149 +- src/__tests__/mailboxes.test.ts | 97 +- src/__tests__/snippets.test.ts | 101 + src/__tests__/suppression.test.ts | 127 +- src/__tests__/templates.test.ts | 145 +- src/__tests__/topics.test.ts | 141 + src/__tests__/validation.test.ts | 144 + src/__tests__/webhooks.test.ts | 131 +- src/__tests__/workflows.test.ts | 109 +- src/client.ts | 28 +- src/index.ts | 4 + src/resources/campaigns.ts | 49 + src/resources/contacts.ts | 109 + src/resources/deliverability.ts | 105 + src/resources/domains.ts | 128 + src/resources/emails.ts | 30 +- src/resources/lists.ts | 106 +- src/resources/mailboxes.ts | 94 +- src/resources/snippets.ts | 74 + src/resources/suppression.ts | 85 + src/resources/templates.ts | 95 +- src/resources/topics.ts | 123 + src/resources/validation.ts | 102 + src/resources/webhooks.ts | 118 + src/resources/workflows.ts | 99 + src/types.generated.ts | 14258 +++++++++---- src/types.ts | 191 +- 41 files changed, 58280 insertions(+), 19698 deletions(-) create mode 100644 src/__tests__/deliverability.test.ts create mode 100644 src/__tests__/snippets.test.ts create mode 100644 src/__tests__/topics.test.ts create mode 100644 src/__tests__/validation.test.ts create mode 100644 src/resources/deliverability.ts create mode 100644 src/resources/snippets.ts create mode 100644 src/resources/topics.ts create mode 100644 src/resources/validation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2a6b9..110a719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,237 @@ All notable changes to `sendly-sdk` are documented here. This project follows [Semantic Versioning](https://semver.org/). +## 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. + +> **Do not publish or deploy 1.1 before the platform deploy that ships the +> renamed wire has gone out.** These types no longer carry `type`, `data` (on +> the v1 event write) or `mailFromStatus`, and an SDK sending `emailCategory` at +> an API that still expects `type` is answered `422 validation_error` on every +> template and campaign write. The order is: platform deploy first, then publish +> the SDKs. + +### 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 compiling 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 reports 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 array that looked like "nothing has happened yet". + + It now returns an explicit field list, `events` as the delivery timeline + (`EmailEvent[]`, oldest first), and `to` filled from the joined contact — a + field the spec had always declared and the response had never carried. + + Fields 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. + +- **`EmailGetResponse` is gone, split in two.** It named the operation rather + than the shape, and was then reused by an operation that is not a GET. There + are now `EmailResponse` (a single email) and `EmailDetailResponse` (an email + plus its delivery events), and **`emails.get` resolves `EmailDetailResponse`**. + A caller who imported the old alias picks the one that matches what they read. + +- **Engagement left the delivery status enum.** `OPENED`, `CLICKED` and + `COMPLAINED` are no longer delivery states on the platform, so they are no + longer members of the status type behind `email.status` or the `status` filter + on `emails.list`. The remaining members 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. + +### 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` — `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, + `deleteV1`, and `topicPreferences`. + - `lists` — `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, + `deleteV1`, and `startValidationRun`. + - `templates` — `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, + `deleteV1`. + - `domains` — `listV1`, `listAllV1`, `createV1`, `getV1`, `verifyV1`, + `deleteV1`, plus the legacy `assignStream`. + - `webhooks` — `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, + `deleteV1`, `rotateSecretV1`. + - `suppression` — `listV1`, `listAllV1`, `createV1`, `getV1`, `deleteV1`. + + 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 fails at runtime rather than at the type check. 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 throws. + +- **`sendly.topics`** — `list`, `listAll`, `create`, `get`, `update`, + `setSubscription`. 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.** + `setSubscription({ subscribed: true })` parks the contact at `pending` and + answers 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`** — `validateEmails`, `getRun`, `listResults`, + `listResultsAll`. **Billed per address checked**: every entry in + `validateEmails({ emails })` costs money, so looping it over a contact list is + looping over your invoice. Validate a whole list with + `lists.startValidationRun`, a background job, and poll it with `getRun`. + + 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`, `listDomainStats`, + `listDomainStatsAll`, `listDmarcReports`, `listDmarcReportsAll`. + `listDomainStats` 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.listFailures`, `campaigns.listFailuresAll` and + `campaigns.retryFailed`.** `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. `retryFailed` 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, `listFailures` also carries `total`: `retryFailed` acts on that number, + and `has_more` alone cannot tell you whether 3 or 30,000 sends failed. + +- **`workflows.getGraph`, `workflows.replaceGraph`, `workflows.clone`, + `workflows.pause` and `workflows.resume`.** + - `replaceGraph` 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({ 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.sendMessage` and `mailboxes.draftMessage`.** The mailbox resource + is no longer read-only. + - `sendMessage` **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`. + - `draftMessage` 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 `*All` companions now number + seventeen: the six from 0.3.0 plus `campaigns.listFailuresAll`, + `contacts.listAllV1`, `deliverability.listDmarcReportsAll`, + `deliverability.listDomainStatsAll`, `domains.listAllV1`, `lists.listAllV1`, + `suppression.listAllV1`, `templates.listAllV1`, `topics.listAll`, + `validation.listResultsAll` and `webhooks.listAllV1`. + +### Fixed + +- **README: `emails.list` was destructured wrongly.** The example read + `page.data.items` and `page.data.cursor`; the response is + `{ success, data: Email[], nextCursor }`, so it is `page.data` and + `page.nextCursor`. Copying the old example did not compile. +- **README: `webhooks.create` was destructured wrongly.** The legacy create + resolves the envelope, so the secret is at `created.data.secret`, not + `const { webhook, secret } = ...`. That destructuring is correct for + `webhooks.createV1`, which is where the example now lives. +- **README: the mailbox resource was described as read-only** in three places. + It is not, since `sendMessage` and `draftMessage`; what stays out of reach is + the mailbox _lifecycle_, which is a different claim. + +### Notes + +- **Pagination is not uniform, and the exception is worth knowing.** Most v1 + lists take `after` and answer `next_cursor`. **`topics.list` and + `validation.listResults` take `cursor` and answer `cursor`.** Both kinds are + forward-only opaque cursors and both stop on `has_more: false`; only the + parameter names differ. `topics.listAll` and `validation.listResultsAll` hide + it — they are hand-rolled rather than routed through `paginateCursor`, which + sends `after` and reads `next_cursor` and would otherwise re-fetch page one + forever. A caller driving pages by hand needs to know which endpoint speaks + which. +- **`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 `idempotencyKey`.** The set of writes that accept + one is the same as in 1.0: `emails.send`, `emails.sendLegacy`, `emails.batch`, + `contacts.create`, `contacts.upsert`, `contacts.bulkCreate`, + `campaigns.create` and `campaigns.send`. `campaigns.retryFailed` 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 e949d45..b6b8b26 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ Official TypeScript SDK for the [Sendly](https://sendly.now) REST API. -Type-safe email, contact, domain, template, webhook, and suppression -operations, plus mailbox and project reads and the versioned `/api/v1` -surface — campaigns, segments, workflows, analytics, and usage. Generated from -the public OpenAPI spec, so every endpoint and schema stays in sync. +Type-safe email, contact, list, topic, domain, template, snippet, webhook and +suppression operations; mailbox reads plus the two composition calls a key may +drive; address validation and deliverability reporting; and the versioned +`/api/v1` surface — campaigns, segments, workflows, analytics, and usage. +Generated from the public OpenAPI spec, so every endpoint and schema stays in +sync. > This repository is the official standalone home and source of truth for the > Sendly TypeScript SDK — issues and PRs are welcome here. Its surface is @@ -69,6 +71,77 @@ const receipt = await sendly.emails.send({ console.log(receipt.id, receipt.status); // status is a real delivery state ``` +## 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: the old field names are gone from these types, and 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** — the one change here worth reading + in full; see below. +- **`EmailGetResponse` is gone.** It named the operation rather than the shape, + and was then reused by an operation that is not a GET. It is now two types: + `EmailResponse` (a single email) and `EmailDetailResponse` (an email plus its + delivery events), and `emails.get` resolves the latter. +- **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 +(`EmailEvent[]`, oldest first), and it fills `to` from the joined contact — which +the spec had always declared and the response had never carried. + +Fields 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 were +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 statuses on the +platform, and the SDK's own status type — the enum behind `email.status` and the +`status` filter on `emails.list` — no longer offers them. A message is +`PENDING`, `SENDING`, `SENT`, `DELIVERED`, `RECEIVED`, `BOUNCED`, `FAILED`, +`REJECTED`, `RENDERING_FAILURE`, `DELIVERY_DELAY` or `CANCELLED`. Engagement is a +separate axis, read from `openedAt` / `clickedAt` / `complainedAt` and the +`opens` / `clicks` counters on the email itself: + +```ts +const { data: email } = await sendly.emails.get(id); +const delivered = email.status === "DELIVERED"; // a delivery fact +const engaged = email.openedAt !== null || 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 @@ -105,6 +178,36 @@ const sendly = new Sendly({ }); ``` +## The resources + +Every resource hangs off the client. A `V1` suffix means the method speaks the +versioned dialect; an unsuffixed method on the same resource speaks the legacy +one. See [Both dialects, one client](#both-dialects-one-client) for why both are +here. + +| `sendly.*` | Methods | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `emails` | `send`, `sendLegacy`, `sendTest`, `batch`, `list`, `get`, `cancelSchedule` | +| `contacts` | `create`, `upsert`, `bulkCreate`, `bulkDelete`, `list`, `get`, `update`, `delete`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1`, `topicPreferences` | +| `lists` | `subscribe`, `unsubscribe`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1`, `startValidationRun` | +| `topics` | `list`, `listAll`, `create`, `get`, `update`, `setSubscription` | +| `templates` | `create`, `list`, `get`, `update`, `delete`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1` | +| `snippets` | `create`, `list`, `get`, `update`, `delete` | +| `domains` | `create`, `list`, `get`, `verify`, `getVerification`, `startSetup`, `assignStream`, `delete`, `listV1`, `listAllV1`, `createV1`, `getV1`, `verifyV1`, `deleteV1` | +| `webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotateSecret`, `listCalls`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1`, `rotateSecretV1` | +| `suppression` | `add`, `list`, `get`, `remove`, `listV1`, `listAllV1`, `createV1`, `getV1`, `deleteV1` | +| `events` | `track`, `record`, `list`, `listAll`, `listNames`, `stats` | +| `campaigns` | `list`, `listAll`, `create`, `get`, `update`, `delete`, `send`, `cancel`, `pause`, `resume`, `stats`, `listFailures`, `listFailuresAll`, `retryFailed` | +| `segments` | `list`, `listAll`, `create`, `get`, `update`, `delete`, `listContacts`, `listContactsAll` | +| `workflows` | `list`, `listAll`, `create`, `get`, `update`, `delete`, `listExecutions`, `listExecutionsAll`, `startExecution`, `cancelExecution`, `stats`, `getGraph`, `replaceGraph`, `clone`, `pause`, `resume` | +| `mailboxes` | `list`, `get`, `listAppPasswords`, `sendMessage`, `draftMessage` | +| `validation` | `validateEmails`, `getRun`, `listResults`, `listResultsAll` | +| `deliverability` | `diagnose`, `listDomainStats`, `listDomainStatsAll`, `listDmarcReports`, `listDmarcReportsAll` | +| `analytics` | `timeseries`, `campaigns`, `topCampaigns` | +| `usage` | `get` | +| `projects` | `get` | +| `verify` | `email` | + ## Common operations ### Send a single email @@ -144,18 +247,39 @@ console.log( ); ``` +### Read an email and its delivery history + +```ts +const { data: email } = await sendly.emails.get(receipt.id); + +console.log(email.to, email.status, email.opens, email.clicks); + +// `events` is the DELIVERY timeline behind `status`, oldest first — not the +// custom events you record with `events.record`, which are read from +// `events.list`. +for (const event of email.events) { + console.log(event.timestamp, event.status); +} +``` + +This is one of the few legacy reads the SDK hands back enveloped rather than +unwrapped, so the email is under `.data`. + ### List emails with filters and cursor pagination ```ts const page = await sendly.emails.list({ limit: 20, tag: "welcome", status: "DELIVERED" }); -for (const email of page.data.items) { +for (const email of page.data) { console.log(email.id, email.to, email.status); } -if (page.data.cursor) { - const next = await sendly.emails.list({ limit: 20, cursor: page.data.cursor }); +if (page.nextCursor) { + const next = await sendly.emails.list({ limit: 20, cursor: page.nextCursor }); } ``` +`status` filters on the delivery lifecycle only. To find the messages somebody +opened, read `openedAt` / `opens` on the rows — engagement is not a status. + ### Upsert a contact ```ts @@ -165,6 +289,39 @@ const contact = await sendly.contacts.upsert({ }); ``` +The v1 half of the resource manages the same contacts with snake_case bodies and +cursor pagination — `contacts.listV1`, `createV1`, `getV1`, `updateV1`, +`deleteV1`, and `contacts.listAllV1` to walk every page: + +```ts +// `subscribed` is the string "true" / "false" here, not a boolean — it is a +// query parameter with three states, and omitting it means "both". +for await (const contact of sendly.contacts.listAllV1({ subscribed: "true" })) { + console.log(contact.email, contact.custom_fields); +} +``` + +Two things about `contacts.updateV1` 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. + +### Read one contact's consent + +```ts +const prefs = await sendly.contacts.topicPreferences(contact.id); + +// `prefs.subscribed` is the global marketing opt-out and OUTRANKS every topic: +// false means nothing marketing reaches them whatever the rows below say. +for (const topic of prefs.topics) { + console.log(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. + ### Manage domains ```ts @@ -190,10 +347,27 @@ Nothing here is reshaped, because finishing setup means a **person** opening `connectUrl` and authorising the change at their registrar. The SDK's job is to hand back the link, not to model the flow behind it. -### Read mailboxes +`assignStream` points a verified identity at one kind of traffic: + +```ts +await sendly.domains.assignStream(domain.id, { + stream: "TRANSACTIONAL", + streamDefault: true, + defaultFromAddress: "receipts@mail.your-domain.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: null` 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. -Receiving mailboxes on the project's verified domains. Reads only — see -[What the SDK deliberately does not expose](#what-the-sdk-deliberately-does-not-expose). +### Mailboxes: read, send, and draft + +Receiving mailboxes on the project's verified domains. The reads are reads; the +two composition calls are not — `sendMessage` really sends. ```ts const mailboxes = await sendly.mailboxes.list(); // not paginated @@ -215,35 +389,283 @@ any of these reads; mailbox credentials are app passwords, created from the dashboard and shown once. `listAppPasswords` returns only the passwords that are still active — a revoked one drops out, so this is not an audit history. -**The per-project cap is 10 mailboxes.** It counts only those holding, or -mid-way to holding, a real account — `PROVISIONING`, `ACTIVE` and `SUSPENDED`. -`FAILED` rows are excluded on purpose, so that a burst of failed provisions -cannot eat a project's allowance and turn an outage into "you have reached your -mailbox limit"; they are still returned by `list()`, so a project that has had -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. +**`sendMessage` sends real mail**, from the mailbox in the path, over its own +domain, and the recipient can reply to it: + +```ts +const sent = await sendly.mailboxes.sendMessage(mailbox.id, { + to: ["customer@example.com"], + subject: "Re: your order", + body: "Shipping tomorrow — tracking to follow.", +}); +console.log(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. + +**`draftMessage` sends nothing.** It asks Sendly's assistant to write text and +hands it back for you to review: + +```ts +const draft = await sendly.mailboxes.draftMessage(mailbox.id, { + mode: "draft", // or "rewrite", or "subject" + brief: "Tell the customer their order ships tomorrow and apologise for the delay.", + tone: "apologetic", +}); +console.log(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 + +```ts +const template = await sendly.templates.create({ + name: "Welcome", + subject: "Welcome to Acme", + body: "

Hi {{ name }}

{{> footer }}", + from: "hello@your-domain.com", + emailCategory: "MARKETING", // was `type` before 1.1 +}); +``` + +`emailCategory` is `MARKETING`, `TRANSACTIONAL` or `SELF_MANAGED_UNSUBSCRIBE` +(the member that used to be called `HEADLESS`). It defaults to `MARKETING` and +is also the legacy list filter: `templates.list({ emailCategory: "MARKETING" })`. + +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`: + +```ts +await sendly.snippets.create({ + name: "footer", + description: "Address block and unsubscribe line", + body: "

Acme Inc, 1 Example Way

", +}); + +const page = await sendly.snippets.list({ limit: 25, search: "footer" }); +console.log(page.data.data.length, page.data.hasMore); +``` + +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. + +### Consent: topics + +A topic is the subject a project mails about — a contact subscribes to a topic +rather than to a campaign, so switching one off silences a whole audience. + +```ts +const topic = await sendly.topics.create({ + key: "product-updates", // stable; survives a rename of `name`, and is not patchable + name: "Product updates", + default_opt_in: true, +}); + +const result = await sendly.topics.setSubscription(topic.id, { + contact_id: contact.id, + subscribed: true, +}); +``` + +**Subscribing somebody through the API does not bypass confirmation.** +`subscribed: true` parks the contact at `pending` and answers 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. + +`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; `update(id, { archived: true })` +is the retire button and drops it from the preference centre and from new sends +while every opt-out survives. `list({ include_archived: true })` brings them back. + +### Validate addresses before you mail them + +**Every address checked is billed.** Looping this over a contact list is looping +over your invoice. + +```ts +const batch = await sendly.validation.validateEmails({ + emails: ["user@example.com", "typo@exmaple.com"], // at most 50 per call +}); + +for (const result of batch.results) { + // Branch on `verdict`, never on the flags: `is_personal` (Gmail, Outlook) and + // `is_role_address` (`support@`) describe ordinary, deliverable addresses. + console.log(result.email, result.verdict); +} +``` + +The 50-address 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: + +```ts +const run = await sendly.lists.startValidationRun(list.id); + +const progress = await sendly.validation.getRun(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. +console.log(progress.status, progress.processed_count, progress.undeliverable_count); + +for await (const result of sendly.validation.listResultsAll(run.id, { verdict: "undeliverable" })) { + console.log(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. + +### Diagnose deliverability + +```ts +const diagnosis = await sendly.deliverability.diagnose({ + domain: "mail.your-domain.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 array means nothing here explains a +// delivery problem. Branch on a finding's `code`, never on its prose. +for (const finding of diagnosis.findings) { + console.log(finding.severity, finding.code); +} +``` + +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. + +`listDomainStats` 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. + +```ts +for await (const row of sendly.deliverability.listDomainStatsAll({ limit: 100 })) { + console.log(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. + +`listDmarcReports` 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. + +```ts +const reports = await sendly.deliverability.listDmarcReports({ limit: 20 }); +for (const report of reports.data) { + console.log(report.org_name, report.policy_domain, report.pass_count, report.fail_count); +} +``` + +`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. ### Subscribe a webhook ```ts -const { webhook, secret } = await sendly.webhooks.create({ +const created = await sendly.webhooks.create({ url: "https://your-app.com/webhooks/sendly", eventTypes: ["email.delivered", "email.bounced", "email.complained"], }); -// store `secret` securely — used to verify HMAC signatures on incoming calls +// store `created.data.secret` securely — used to verify HMAC signatures +``` + +On v1 the same registration resolves the secret beside the webhook, and adds +rotation: + +```ts +const { webhook, secret } = await sendly.webhooks.createV1({ + url: "https://your-app.com/webhooks/sendly", + event_types: ["email.delivered", "email.bounced"], +}); + +const rotated = await sendly.webhooks.rotateSecretV1(webhook.id); +console.log(rotated.secret, rotated.previous_secret_expires_at); ``` +`createV1` and `rotateSecretV1` 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. + ### Add to the suppression list ```ts await sendly.suppression.add({ email: "angry@example.com", reason: "MANUAL" }); ``` +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: + +```ts +import { SendlyNotFoundError } from "sendly-sdk"; + +try { + const record = await sendly.suppression.getV1("angry@example.com"); + console.log("suppressed:", record.reason, record.source); +} catch (err) { + if (err instanceof SendlyNotFoundError) { + // not suppressed — mail may flow + } else throw err; +} +``` + +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. + +`deleteV1` 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. + ### Track a custom event -Records a custom event against a contact. Works with both `sk_*` and `pk_*` -keys (reserved system event names are rejected). +Records a custom event against a contact. The legacy `events.track` works with +both `sk_*` and `pk_*` keys (reserved system event names are rejected) and +carries its payload in `data`: ```ts const tracked = await sendly.events.track({ @@ -254,6 +676,20 @@ const tracked = await sendly.events.track({ console.log(tracked.contact, tracked.event); ``` +`events.record` is the same capability on `/api/v1/events`, and its payload field +is called `payload`: + +```ts +const event = await sendly.events.record({ + name: "purchase.completed", + contact_id: contact.id, // must already exist — this endpoint never creates contacts + payload: { plan: "pro", amount: 4900 }, +}); +``` + +New integrations should prefer `record`, which also unlocks `events.list`, +`events.listNames` and `events.stats`. + ### Verify an email address ```ts @@ -263,11 +699,17 @@ if (!check.valid) { } ``` +This is the free single-address syntax/MX check. It is not +`validation.validateEmails`, which is the billed batch check with a verdict +vocabulary behind it. + ## The `/api/v1` surface -Campaigns, segments, workflows, analytics, usage, and events live on Sendly's -versioned API. They hang off the same client and the same base URL, but they -speak a different dialect from the `/api/*` resources above: +Campaigns, segments, workflows, analytics, usage, topics, validation, +deliverability and events live on Sendly's versioned API, as does the `V1` half +of contacts, lists, templates, domains, webhooks and suppression. They hang off +the same client and the same base URL, but they speak a different dialect from +the `/api/*` resources above: - **Responses are the bare resource**, not a `{ success, data }` envelope, and fields are `snake_case`. @@ -296,12 +738,36 @@ const stats = await sendly.campaigns.stats(campaign.id); console.log(stats.delivered, stats.open_rate); ``` +### Both dialects, one client + +Six resources — contacts, lists, templates, domains, webhooks and suppression — +now answer on both surfaces, so their v1 methods carry a `V1` suffix: +`contacts.list` is the legacy one, `contacts.listV1` 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: + +```ts +const legacy = await sendly.contacts.list({ limit: 20 }); +legacy.data.data; // Contact[] — inside the `{ success, data }` envelope +legacy.data.nextCursor; // camelCase + +const v1 = await sendly.contacts.listV1({ limit: 20 }); +v1.data; // ContactV1[] — 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. + ### Pagination -Every v1 list takes `limit` (1–100, default 20) and `after` (an opaque cursor -from the previous response's `next_cursor`). Page manually, or let the SDK do -it — each list has a companion `*All` async generator that walks the pages and -yields individual items: +Most v1 lists take `limit` (1–100, default 20) and `after` (an opaque cursor from +the previous response's `next_cursor`). Page manually, or let the SDK do it — +each list has a companion `*All` async generator that walks the pages and yields +individual items: ```ts // Manual: stop when has_more goes false. @@ -310,24 +776,136 @@ while (page.has_more && page.next_cursor) { page = await sendly.campaigns.list({ limit: 50, after: page.next_cursor }); } -// Automatic: campaigns.listAll, segments.listAll, segments.listContactsAll, -// workflows.listAll, workflows.listExecutionsAll, events.listAll. +// Automatic: for await (const campaign of sendly.campaigns.listAll({ limit: 50 })) { console.log(campaign.id, campaign.status); } ``` +The seventeen companions: `campaigns.listAll`, `campaigns.listFailuresAll`, +`contacts.listAllV1`, `deliverability.listDmarcReportsAll`, +`deliverability.listDomainStatsAll`, `domains.listAllV1`, `events.listAll`, +`lists.listAllV1`, `segments.listAll`, `segments.listContactsAll`, +`suppression.listAllV1`, `templates.listAllV1`, `topics.listAll`, +`validation.listResultsAll`, `webhooks.listAllV1`, `workflows.listAll` and +`workflows.listExecutionsAll`. + +**Two endpoints name their cursor differently, and this is the one genuinely +surprising thing in the surface.** `topics.list` and `validation.listResults` +take `cursor` and answer `cursor`, where every other v1 list takes `after` and +answers `next_cursor`. Both kinds are forward-only opaque cursors and both stop +on `has_more: false`; only the parameter names differ. + +```ts +// after / next_cursor — every list except the two below. +let page = await sendly.templates.listV1({ limit: 50 }); +page = await sendly.templates.listV1({ limit: 50, after: page.next_cursor! }); + +// cursor / cursor — topics and validation results. +let topics = await sendly.topics.list({ limit: 50 }); +topics = await sendly.topics.list({ limit: 50, cursor: topics.cursor! }); +``` + +`topics.listAll` and `validation.listResultsAll` hide the difference — they are +hand-rolled for exactly this reason, because the shared `paginateCursor` helper +sends `after` and reads `next_cursor` and would re-fetch page one forever. Drive +pages by hand only if you know which of the two a given endpoint speaks. + Keep the filter and sort arguments **fixed for the whole walk** — the cursor encodes them, and changing them mid-pagination is answered with `422 validation_error` telling you to restart from the first page. There is -deliberately no total count. +deliberately no total count. The one exception is `campaigns.listFailures`, which +also carries `total`, because `retryFailed` acts on that number and `has_more` +alone cannot tell you whether 3 or 30,000 sends failed. + +### Campaigns: who did not get it, and re-driving them + +`stats` says how many sends failed; only `listFailures` says who. + +```ts +const failures = await sendly.campaigns.listFailures(campaign.id, { limit: 100 }); +console.log(failures.total, "recipients did not receive it"); + +for await (const failure of sendly.campaigns.listFailuresAll(campaign.id)) { + console.log(failure.email, failure.reason, failure.failed_at); +} + +const retry = await sendly.campaigns.retryFailed(campaign.id); +console.log("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 `null` on rows recorded before reasons +were captured. + +`retryFailed` 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 resolves 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`. + +### Workflows: the graph, and the lifecycle + +`getGraph` returns every step — including the `TRIGGER` entry node — plus the +directed transitions between them, and that body is accepted verbatim by +`replaceGraph`: + +```ts +const graph = await sendly.workflows.getGraph(workflow.id); +graph.steps[0].config; // stored exactly as authored, camelCase keys and all + +const updated = await sendly.workflows.replaceGraph(workflow.id, { + steps: graph.steps, + transitions: graph.transitions, +}); +``` + +`replaceGraph` 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. + +```ts +const copy = await sendly.workflows.clone(workflow.id, { name: "Welcome (v2 test)" }); +``` + +`pause` and `resume` are deliberately asymmetric: + +```ts +const paused = await sendly.workflows.pause(workflow.id); +console.log("cancelled", paused.cancelled_executions, "in-flight runs"); + +const resumed = await sendly.workflows.resume(workflow.id); +console.log(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. ### Events: `track` vs `record` -`events.track` is the legacy `POST /api/track` endpoint and is unchanged. -`events.record` is the same capability on `/api/v1/events` — a different name -only because `track` was taken. New integrations should prefer `record`, which -also unlocks `events.list`, `events.listNames`, and `events.stats`. +`events.track` is the legacy `POST /api/track` endpoint and is unchanged — its +payload field is still `data`. `events.record` is the same capability on +`/api/v1/events`, named differently only because `track` was taken, and its +payload field is `payload`. New integrations should prefer `record`, which also +unlocks `events.list`, `events.listNames`, and `events.stats`. ### Emails: `send` vs `sendLegacy` @@ -395,9 +973,11 @@ 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 (`mailboxes.list`, `mailboxes.get`, -`mailboxes.listAppPasswords`) — 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 (`mailboxes.list`, `mailboxes.get`, +`mailboxes.listAppPasswords`) have a conditional membership check, and +`mailboxes.sendMessage` / `mailboxes.draftMessage` publish `ApiKeyAuth` outright, +so a key really can call all five. ## Error handling @@ -478,6 +1058,10 @@ hint, note that `X-RateLimit-Reset` is an **absolute** epoch-seconds instant whereas the draft-11 `RateLimit` header's `t=` is **delta** seconds. The SDK does not retry on your behalf. +Note that `404 resource_not_found` is an ordinary answer from +`suppression.getV1`, not a failure: it is how that route says "this address is +not suppressed". Catch it rather than logging it. + ### Legacy `/api/*` errors Invalid input is reported as `SendlyValidationError`. The API returns **422** @@ -505,10 +1089,17 @@ Pass `idempotencyKey` on any write that supports it — `emails.send`, retries safe. Replays within 24 hours return the original result instead of acting twice. -Two v1 writes deliberately take no key. `events.record` is append-only and -high-volume. `emails.sendTest` reaches only the caller's own inbox, a daily -cap already bounds it, and "send me another one" is the normal second call -rather than a mistake worth deduplicating. +Nothing added in 1.1 takes a key. The v1 creates (`contacts.createV1`, +`lists.createV1`, `templates.createV1`, `domains.createV1`, +`webhooks.createV1`, `suppression.createV1`, `topics.create`, +`snippets.create`) are all either naturally idempotent on their own key or cheap +to repeat, and `campaigns.retryFailed` is guarded by a `409` on a retry already +running rather than by a replay ledger. + +Two v1 writes deliberately take no key for reasons of their own. `events.record` +is append-only and high-volume. `emails.sendTest` reaches only the caller's own +inbox, a daily cap already bounds it, and "send me another one" is the normal +second call rather than a mistake worth deduplicating. ```ts await sendly.emails.send({ from, to, subject, body }, { idempotencyKey: `signup-${userId}` }); diff --git a/dist/index.cjs b/dist/index.cjs index 38e9924..d0f67e2 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -25,6 +25,7 @@ __export(index_exports, { ContactsResource: () => ContactsResource, DEFAULT_BASE_URL: () => DEFAULT_BASE_URL, DEFAULT_TOLERANCE_MS: () => DEFAULT_TOLERANCE_MS, + DeliverabilityResource: () => DeliverabilityResource, DomainsResource: () => DomainsResource, EmailsResource: () => EmailsResource, EventsResource: () => EventsResource, @@ -43,9 +44,12 @@ __export(index_exports, { SendlyRateLimitError: () => SendlyRateLimitError, SendlyServerError: () => SendlyServerError, SendlyValidationError: () => SendlyValidationError, + SnippetsResource: () => SnippetsResource, SuppressionResource: () => SuppressionResource, TemplatesResource: () => TemplatesResource, + TopicsResource: () => TopicsResource, UsageResource: () => UsageResource, + ValidationResource: () => ValidationResource, VerifyResource: () => VerifyResource, WebhooksResource: () => WebhooksResource, WorkflowsResource: () => WorkflowsResource, @@ -211,6 +215,45 @@ var CampaignsResource = class { path: `/api/v1/campaigns/${encodeURIComponent(id)}/stats` }); } + /** + * The recipients this campaign did not reach, and why. + * + * {@link 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 it is `null` on rows recorded before + * reasons were captured. + * + * Cursor-paginated like every other v1 list, but uniquely it also carries + * `total`: {@link retryFailed} acts on that number, and `has_more` alone + * cannot tell you whether 3 or 30,000 sends failed. + */ + async listFailures(id, query) { + return this.client.request({ + method: "GET", + path: `/api/v1/campaigns/${encodeURIComponent(id)}/failures`, + query + }); + } + /** Iterate every failed send across pages, yielding one recipient at a time. */ + async *listFailuresAll(id, query) { + yield* paginateCursor((after) => this.listFailures(id, { ...query, after }), query?.after); + } + /** + * 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, not re-sent. + * + * The walk runs in the background, so this resolves 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. + */ + async retryFailed(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/campaigns/${encodeURIComponent(id)}/retry-failed` + }); + } }; // src/resources/contacts.ts @@ -289,6 +332,174 @@ var ContactsResource = class { noContent: true }); } + /** + * List contacts on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after` with no total count, narrowed by + * `search` (case-insensitive substring on the address) and `subscribed`. + * Hold the filters steady for the whole walk — the cursor encodes them, and + * changing one mid-pagination returns `422 validation_error` asking you to + * restart. {@link listAllV1} drives the loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/contacts", + query + }); + } + /** Iterate every v1 contact across pages, yielding one contact at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Create a contact. Only `email` is required — `subscribed` defaults to true + * server-side, and `custom_fields` is arbitrary JSON that templates can read + * back as `{{ variables }}`. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/contacts", + body + }); + } + /** + * Retrieve a single contact by id. v1 has no lookup-by-address route — reach + * a contact you only know the email of through {@link listV1}'s `search`. + */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/contacts/${encodeURIComponent(id)}` + }); + } + /** + * Patch a contact. Only the fields you send are changed, with two caveats. + * + * `email` is not patchable at all: an address is the contact's identity here, + * 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. Sending a partial object silently drops the rest. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/contacts/${encodeURIComponent(id)}`, + body + }); + } + /** + * Delete a contact. Unlike the legacy {@link delete}, this resolves the + * `{ id, deleted }` acknowledgement rather than discarding it. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/contacts/${encodeURIComponent(id)}` + }); + } + /** + * 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 nothing 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. + */ + async topicPreferences(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/contacts/${encodeURIComponent(id)}/topics` + }); + } +}; + +// src/resources/deliverability.ts +var DeliverabilityResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * Diagnose one of your SENDING domains: 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. + * + * `query.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. + */ + async diagnose(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/diagnose", + query + }); + } + /** + * Delivery outcomes broken out 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 {@link 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. + */ + async listDomainStats(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/domains", + query + }); + } + /** Iterate every recipient-domain row across pages, one day-and-domain at a time. */ + async *listDomainStatsAll(query) { + yield* paginateCursor((after) => this.listDomainStats({ ...query, after }), query?.after); + } + /** + * DMARC aggregate (RUA) reports that receiving providers have sent about your + * domains, newest reporting 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). + * + * `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. + */ + async listDmarcReports(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/dmarc", + query + }); + } + /** Iterate every DMARC report across pages, one report at a time. */ + async *listDmarcReportsAll(query) { + yield* paginateCursor((after) => this.listDmarcReports({ ...query, after }), query?.after); + } }; // src/resources/domains.ts @@ -361,6 +572,29 @@ var DomainsResource = class { }); return this.client.unwrap(envelope); } + /** + * 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: null` 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. + */ + async assignStream(id, body) { + const envelope = await this.client.request({ + method: "PATCH", + path: `/api/domains/${encodeURIComponent(id)}`, + body + }); + return this.client.unwrap(envelope); + } /** Delete a domain. */ async delete(id) { await this.client.request({ @@ -368,6 +602,88 @@ var DomainsResource = class { path: `/api/domains/${encodeURIComponent(id)}` }); } + /** + * List sending domains, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. {@link listAllV1} + * drives the loop 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. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/domains", + query + }); + } + /** Iterate every sending domain across pages, yielding one domain at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * 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 {@link verifyV1} 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. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/domains", + body + }); + } + /** Retrieve a single sending domain. */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/domains/${encodeURIComponent(id)}` + }); + } + /** + * Re-read the domain's state from SES and DNS, and resolve the refreshed + * document. + * + * 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. + */ + async verifyV1(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/domains/${encodeURIComponent(id)}/verify` + }); + } + /** + * Remove a sending domain. Resolves `{ id, deleted }`. + * + * 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. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/domains/${encodeURIComponent(id)}` + }); + } }; // src/resources/emails.ts @@ -450,14 +766,28 @@ var EmailsResource = class { query }); } - /** Fetch a single email and its delivery events. */ + /** + * 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 along with it. + */ async get(id) { return this.client.request({ method: "GET", path: `/api/emails/${encodeURIComponent(id)}` }); } - /** Cancel a scheduled (PENDING) email before it fires. */ + /** + * Cancel a scheduled (PENDING) email before it fires. + * + * Resolves the email itself, not an empty acknowledgement: the contract has + * always published `EmailResponse` here, and the caller wants the row's new + * status more than it wants a `{ success: true }` it already inferred from the + * absence of an exception. + */ async cancelSchedule(id) { return this.client.request({ method: "DELETE", @@ -559,8 +889,8 @@ var ListsResource = class { * **Double opt-in.** When the list has `doubleOptIn` enabled the membership * is created as `PENDING` and the result carries a `confirmToken`. Sendly * does **not** send the confirmation email — your application must deliver - * `/api/lists/confirm?token=` to the contact itself. The token - * is valid for 24 hours. + * `/api/lists/confirm-subscription?token=` to the contact + * itself. The token is valid for 24 hours. * * **Re-subscribing after an opt-out.** If the email already holds an * `UNSUBSCRIBED` membership on this list, the call fails with @@ -590,6 +920,83 @@ var ListsResource = class { }); return this.client.unwrap(envelope); } + /** + * List the project's subscriber lists on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold the + * arguments steady for the whole walk — changing them mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/lists", + query + }); + } + /** Iterate every list across pages, yielding one list at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Create a list. Only `name` is required; `double_opt_in` defaults to false. + * + * Turning double opt-in on does not make Sendly send anything — it only + * changes {@link subscribe} to create the membership as `PENDING` and hand + * back the `confirmToken` your application delivers. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/lists", + body + }); + } + /** + * Retrieve 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. + */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/lists/${encodeURIComponent(id)}` + }); + } + /** + * 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. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/lists/${encodeURIComponent(id)}`, + body + }); + } + /** Delete a list. Resolves `{ id, deleted }`. Removes the list, not its contacts. */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/lists/${encodeURIComponent(id)}` + }); + } + /** + * 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.getRun`. + */ + async startValidationRun(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/lists/${encodeURIComponent(id)}/validation-runs` + }); + } }; // src/resources/mailboxes.ts @@ -647,6 +1054,57 @@ var MailboxesResource = class { }); return this.client.unwrap(envelope); } + /** + * 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. + */ + async sendMessage(id, body) { + const envelope = await this.client.request({ + method: "POST", + path: `/api/mailboxes/${encodeURIComponent(id)}/messages`, + body + }); + return this.client.unwrap(envelope); + } + /** + * 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 {@link sendMessage} + * 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. + */ + async draftMessage(id, body) { + const envelope = await this.client.request({ + method: "POST", + path: `/api/mailboxes/${encodeURIComponent(id)}/drafts`, + body + }); + return this.client.unwrap(envelope); + } }; // src/resources/projects.ts @@ -742,6 +1200,63 @@ var SegmentsResource = class { } }; +// src/resources/snippets.ts +var SnippetsResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * Create a snippet. `name` is the literal identifier templates include with + * `{{> name}}` and is unique within the project, so a clash answers 409. + */ + async create(body) { + const envelope = await this.client.request({ + method: "POST", + path: "/api/snippets", + body + }); + return this.client.unwrap(envelope); + } + /** List snippets with cursor pagination (`limit`/`cursor`) + optional `search` over name and description. */ + async list(query) { + return this.client.request({ + method: "GET", + path: "/api/snippets", + query + }); + } + /** Fetch a single snippet by id. */ + async get(id) { + const envelope = await this.client.request({ + method: "GET", + path: `/api/snippets/${encodeURIComponent(id)}` + }); + return this.client.unwrap(envelope); + } + /** Patch an existing snippet. */ + async update(id, body) { + const envelope = await this.client.request({ + method: "PATCH", + path: `/api/snippets/${encodeURIComponent(id)}`, + body + }); + return this.client.unwrap(envelope); + } + /** + * Delete a snippet. The API answers 200 with `{ success, data: { id } }`; the + * SDK resolves void. Templates that still include it keep rendering — an + * absent snippet renders as an empty string, like an absent variable. + */ + async delete(id) { + await this.client.request({ + method: "DELETE", + path: `/api/snippets/${encodeURIComponent(id)}`, + noContent: true + }); + } +}; + // src/resources/suppression.ts var SuppressionResource = class { constructor(client) { @@ -780,6 +1295,70 @@ var SuppressionResource = class { noContent: true }); } + /** + * List suppressed addresses, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold `reason` + * steady for the whole walk — changing it mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/suppressions", + query + }); + } + /** Iterate every suppressed address across pages, yielding one record at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * 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. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/suppressions", + body + }); + } + /** + * Retrieve 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. + */ + async getV1(email) { + return this.client.request({ + method: "GET", + path: `/api/v1/suppressions/${encodeURIComponent(email)}` + }); + } + /** + * Un-suppress an address: mail can flow to it again. Resolves + * `{ email, deleted }`. + * + * 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. + */ + async deleteV1(email) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/suppressions/${encodeURIComponent(email)}` + }); + } }; // src/resources/templates.ts @@ -797,7 +1376,7 @@ var TemplatesResource = class { }); return this.client.unwrap(envelope); } - /** List templates with cursor pagination (`limit`/`cursor`) + optional type filter. */ + /** List templates with cursor pagination (`limit`/`cursor`) + optional `emailCategory` filter. */ async list(query) { return this.client.request({ method: "GET", @@ -830,6 +1409,179 @@ var TemplatesResource = class { noContent: true }); } + /** + * List templates, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. `search` here + * matches the name only — narrower than the dashboard's search, which also + * reads description and subject. Hold `search` and `email_category` steady + * for the whole walk; changing either mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/templates", + query + }); + } + /** Iterate every template across pages, yielding one template at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Create a template. `email_category` defaults to `MARKETING` server-side. + * + * 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. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/templates", + body + }); + } + /** Retrieve a single template. */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/templates/${encodeURIComponent(id)}` + }); + } + /** + * Patch a template. Only the fields you send are changed. + * + * 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. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/templates/${encodeURIComponent(id)}`, + body + }); + } + /** + * Delete a template. Resolves `{ id, deleted }` — the legacy `delete` above + * discards that body, 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. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/templates/${encodeURIComponent(id)}` + }); + } +}; + +// src/resources/topics.ts +var TopicsResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * List topics, newest first. + * + * Archived topics are omitted unless `include_archived` asks for them. There + * is no delete — archiving is the retire button, because a topic is where + * people's answers are recorded. {@link listAll} drives the loop for you. + * + * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + */ + async list(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/topics", + query + }); + } + /** + * Iterate every topic across pages, yielding one topic at a time. + * + * The walk is written out here rather than delegated to `paginateCursor` + * because this endpoint names its cursor `cursor` on both sides — the query + * parameter and the response field — where every other v1 list takes `after` + * and answers `next_cursor`. + */ + async *listAll(query) { + let cursor = query?.cursor; + for (; ; ) { + const page = await this.list({ ...query, cursor }); + for (const topic of page.data) { + yield topic; + } + const next = page.cursor; + if (!page.has_more || next === null || next === cursor) return; + cursor = next; + } + } + /** + * Create a topic. + * + * `key` is the stable name every preference form and integration refers to, + * so it survives a rename of `name` and cannot be changed afterwards. + * + * `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. + */ + async create(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/topics", + body + }); + } + /** Retrieve a single topic. */ + async get(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/topics/${encodeURIComponent(id)}` + }); + } + /** + * Patch a topic. Only the fields you send are changed. + * + * `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. + */ + async update(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/topics/${encodeURIComponent(id)}`, + body + }); + } + /** + * Record what one contact wants on one topic. The two directions are not + * symmetric, on purpose. + * + * `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. + */ + async setSubscription(id, body) { + return this.client.request({ + method: "POST", + path: `/api/v1/topics/${encodeURIComponent(id)}/subscriptions`, + body + }); + } }; // src/resources/usage.ts @@ -854,6 +1606,89 @@ var UsageResource = class { } }; +// src/resources/validation.ts +var ValidationResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * 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 + * (`lists.startValidationRun`) 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. + */ + async validateEmails(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/email-validations", + body + }); + } + /** + * Retrieve a bulk validation run: how far it has got, and what it found. + * + * The other way a run starts is `lists.startValidationRun`, 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. + */ + async getRun(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/validation-runs/${encodeURIComponent(id)}` + }); + } + /** + * 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. + * + * This list pages on `cursor`, not the `after` every other v1 collection + * takes, and its envelope carries the next page under `cursor` rather than + * `next_cursor`. {@link listResultsAll} drives that loop for you. + */ + async listResults(id, query) { + return this.client.request({ + method: "GET", + path: `/api/v1/validation-runs/${encodeURIComponent(id)}/results`, + query + }); + } + /** + * Iterate every result across pages, yielding one address's verdict at a time. + * + * Hand-rolled rather than routed through `paginateCursor`: the shared helper + * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on + * both sides, so the helper would send an ignored parameter and re-fetch page + * one forever. Stops on `has_more: false`, a null cursor, or a cursor the + * server repeats. + */ + async *listResultsAll(id, query) { + let cursor = query?.cursor; + for (; ; ) { + const page = await this.listResults(id, { ...query, cursor }); + for (const result of page.data ?? []) { + yield result; + } + const next = page.cursor; + if (!page.has_more || next === null || next === void 0 || next === cursor) return; + cursor = next; + } + } +}; + // src/resources/verify.ts var VerifyResource = class { constructor(client) { @@ -938,6 +1773,99 @@ var WebhooksResource = class { query }); } + /** + * List webhook endpoints, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. + * {@link listAllV1} drives the loop for you. Signing secrets are not on this + * response — see {@link rotateSecretV1} if you have lost one. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/webhooks", + query + }); + } + /** Iterate every webhook endpoint across pages, yielding one endpoint at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Register an endpoint to receive HMAC-signed deliveries for the events named + * in `event_types`. + * + * Resolves `{ webhook, secret }`, and this is one of only two calls that ever + * carry the signing secret — {@link rotateSecretV1} 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 + * `verifySignature` to authenticate the deliveries that arrive at your + * endpoint. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/webhooks", + body + }); + } + /** Retrieve a single webhook endpoint. The signing secret is not on this response. */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/webhooks/${encodeURIComponent(id)}` + }); + } + /** + * 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. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/webhooks/${encodeURIComponent(id)}`, + body + }); + } + /** + * 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. Resolves `{ id, deleted }`. Deliveries already in flight are not + * recalled, so the endpoint may still receive an event shortly after this. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/webhooks/${encodeURIComponent(id)}` + }); + } + /** + * Mint a fresh signing secret for an endpoint. + * + * The new plaintext is returned exactly once, here — this and + * {@link createV1} 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 + * `verifySignature`. 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. + */ + async rotateSecretV1(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/webhooks/${encodeURIComponent(id)}/rotate-secret` + }); + } }; // src/resources/workflows.ts @@ -1047,6 +1975,96 @@ var WorkflowsResource = class { query }); } + /** + * Every step in the workflow — including its `TRIGGER` entry node — 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 {@link replaceGraph} — read, edit one step, + * send it back. + */ + async getGraph(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/workflows/${encodeURIComponent(id)}/graph` + }); + } + /** + * 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. {@link pause} first. + */ + async replaceGraph(id, body) { + return this.client.request({ + method: "PUT", + path: `/api/v1/workflows/${encodeURIComponent(id)}/graph`, + body + }); + } + /** + * 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 `. + */ + async clone(id, body) { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/clone`, + body + }); + } + /** + * Disable the workflow *and cancel every `RUNNING`/`WAITING` execution in it*, + * resolving `{ 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: {@link resume} re-opens the workflow to new + * runs, it does not put the cancelled contacts back where they were. + */ + async pause(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/pause` + }); + } + /** + * 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. + */ + async resume(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/resume` + }); + } }; // src/errors.ts @@ -1164,7 +2182,7 @@ function errorFromResponse(statusCode, errorCode, message, body, contentType) { } // src/client.ts -var SDK_VERSION = "1.0.0"; +var SDK_VERSION = "1.1.0"; var DEFAULT_BASE_URL = "https://api.sendly.now"; var Sendly = class { emails; @@ -1176,6 +2194,8 @@ var Sendly = class { events; verify; lists; + /** Reusable body fragments a template includes with `{{> name}}`. */ + snippets; /** Receiving mailboxes. Reads only — the writes need a user, not an API key. */ mailboxes; /** Campaigns on the versioned `/api/v1` surface. */ @@ -1190,6 +2210,12 @@ var Sendly = class { usage; /** The project this key belongs to, on the versioned `/api/v1` surface. */ projects; + /** Consent topics and what each contact has said they want. */ + topics; + /** Address validation — one batch, or a whole list. */ + validation; + /** Why mail from your domains is or is not arriving. */ + deliverability; apiKey; baseUrl; fetchImpl; @@ -1220,6 +2246,7 @@ var Sendly = class { this.events = new EventsResource(this); this.verify = new VerifyResource(this); this.lists = new ListsResource(this); + this.snippets = new SnippetsResource(this); this.mailboxes = new MailboxesResource(this); this.campaigns = new CampaignsResource(this); this.segments = new SegmentsResource(this); @@ -1227,6 +2254,9 @@ var Sendly = class { this.analytics = new AnalyticsResource(this); this.usage = new UsageResource(this); this.projects = new ProjectsResource(this); + this.topics = new TopicsResource(this); + this.validation = new ValidationResource(this); + this.deliverability = new DeliverabilityResource(this); } /** * Low-level request helper. Resources call this; consumers can call it @@ -1386,6 +2416,7 @@ function constructEvent(payload, signature, timestamp, secret, options = {}) { ContactsResource, DEFAULT_BASE_URL, DEFAULT_TOLERANCE_MS, + DeliverabilityResource, DomainsResource, EmailsResource, EventsResource, @@ -1404,9 +2435,12 @@ function constructEvent(payload, signature, timestamp, secret, options = {}) { SendlyRateLimitError, SendlyServerError, SendlyValidationError, + SnippetsResource, SuppressionResource, TemplatesResource, + TopicsResource, UsageResource, + ValidationResource, VerifyResource, WebhooksResource, WorkflowsResource, diff --git a/dist/index.d.cts b/dist/index.d.cts index 4c7ad0b..7e54f05 100644 --- a/dist/index.d.cts +++ b/dist/index.d.cts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ interface paths { - "/api/contacts": { + "/api/v1/campaigns": { parameters: { query?: never; header?: never; @@ -11,55 +11,69 @@ interface paths { cookie?: never; }; /** - * List contacts - * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. + * List campaigns + * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - get: operations["listContacts"]; + get: operations["v1ListCampaigns"]; put?: never; /** - * Create a contact - * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. + * Create a campaign + * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * The `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. + * + * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["createContact"]; + post: operations["v1CreateCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/bulk": { + "/api/v1/campaigns/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Bulk-create contacts - * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. + * Retrieve a campaign + * @description Fetch one campaign, including its materialized delivery counters. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["bulkCreateContacts"]; + get: operations["v1GetCampaign"]; + put?: never; + post?: never; /** - * Bulk-delete contacts - * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. + * Delete a campaign + * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["bulkDeleteContacts"]; + delete: operations["v1DeleteCampaign"]; options?: never; head?: never; - patch?: never; + /** + * Update a campaign + * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * + * Changing `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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + */ + patch: operations["v1UpdateCampaign"]; trace?: never; }; - "/api/contacts/upsert": { + "/api/v1/campaigns/{id}/send": { parameters: { query?: never; header?: never; @@ -69,131 +83,115 @@ interface paths { get?: never; put?: never; /** - * Create or update a contact by email - * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. + * Send or schedule a campaign + * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * **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. + * + * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * + * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. */ - post: operations["upsertContact"]; + post: operations["v1SendCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/{id}": { + "/api/v1/campaigns/{id}/cancel": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a contact - * @description Requires the `contacts:read` scope — View your contacts and their custom fields. - */ - get: operations["getContact"]; + get?: never; put?: never; - post?: never; /** - * Delete a contact - * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). + * Cancel a campaign + * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["deleteContact"]; + post: operations["v1CancelCampaign"]; + delete?: never; options?: never; head?: never; - /** - * Update a contact - * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - patch: operations["updateContact"]; + patch?: never; trace?: never; }; - "/api/domains": { + "/api/v1/campaigns/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List sending domains - * @description List all domains for the authenticated project. - * - * Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["listDomains"]; + get?: never; put?: never; /** - * Add a sending domain - * @description Register a new domain with SES and persist its DKIM tokens. + * Pause a sending campaign + * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["addDomain"]; + post: operations["v1PauseCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}": { + "/api/v1/campaigns/{id}/resume": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a sending domain - * @description Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["getDomain"]; + get?: never; put?: never; - post?: never; /** - * Remove a sending domain - * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. + * Resume a paused campaign + * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["deleteDomain"]; + post: operations["v1ResumeCampaign"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/dodomain-session": { + "/api/v1/campaigns/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Start guided DNS setup - * @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. - * - * The 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. + * Retrieve campaign statistics + * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. * - * `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. + * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["startDomainSetup"]; + get: operations["v1GetCampaignStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/verify": { + "/api/v1/campaigns/{id}/failures": { parameters: { query?: never; header?: never; @@ -201,79 +199,83 @@ interface paths { cookie?: never; }; /** - * Read SES verification status - * @description Read the current SES verification status without forcing a refresh. + * List a campaign's failed sends + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - get: operations["getDomainVerification"]; - put?: never; - /** - * Trigger SES verification - * @description Force a refresh of the domain's SES verification status. + * `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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Cursor-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. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["verifyDomain"]; + get: operations["v1ListCampaignFailures"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails": { + "/api/v1/campaigns/{id}/retry-failed": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List emails - * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. - * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. - */ - get: operations["listEmails"]; + get?: never; put?: never; /** - * Send a single transactional email - * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * Retry a campaign's failed sends + * @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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * The 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. + * + * Only 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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["sendEmail"]; + post: operations["v1RetryCampaignFailures"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/batch": { + "/api/v1/segments": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List segments + * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * + * `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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. + */ + get: operations["v1ListSegments"]; put?: never; /** - * Send a batch of emails - * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. + * Create a segment + * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. * - * The 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. + * A `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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - post: operations["sendEmailBatch"]; + post: operations["v1CreateSegment"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}": { + "/api/v1/segments/{id}": { parameters: { query?: never; header?: never; @@ -281,89 +283,129 @@ interface paths { cookie?: never; }; /** - * Get a single email - * @description Fetch one email along with its delivery events. + * Retrieve a segment + * @description Fetch one segment, including its saved `condition` and materialized `member_count`. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["getEmail"]; + get: operations["v1GetSegment"]; put?: never; post?: never; - delete?: never; + /** + * Delete a segment + * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + delete: operations["v1DeleteSegment"]; options?: never; head?: never; - patch?: never; + /** + * Update a segment + * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * + * `type` is not accepted here — see the create operation. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + patch: operations["v1UpdateSegment"]; trace?: never; }; - "/api/emails/{id}/schedule": { + "/api/v1/segments/{id}/contacts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Cancel a scheduled (still-PENDING) email - * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. + * List the contacts in a segment + * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. + * + * Cursors 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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - delete: operations["cancelScheduledEmail"]; + get: operations["v1ListSegmentContacts"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/lists/{id}/subscribe": { + "/api/v1/workflows": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Subscribe a contact to a list - * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm?token=` to the contact itself. + * List workflows + * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. * - * **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. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1ListWorkflows"]; + put?: never; + /** + * Create a workflow + * @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`. * - * `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. + * Pass `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["subscribeToList"]; + post: operations["v1CreateWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/lists/{id}/unsubscribe": { + "/api/v1/workflows/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Retrieve a workflow + * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. + * + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1GetWorkflow"]; put?: never; + post?: never; /** - * Unsubscribe a contact from a list - * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. + * Delete a workflow + * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. * - * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["unsubscribeFromList"]; - delete?: never; + delete: operations["v1DeleteWorkflow"]; options?: never; head?: never; - patch?: never; + /** + * Update a workflow + * @description Sparse update — omitted fields are left unchanged. + * + * Two 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. + * + * `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + patch: operations["v1UpdateWorkflow"]; trace?: never; }; - "/api/mailboxes": { + "/api/v1/workflows/{id}/executions": { parameters: { query?: never; header?: never; @@ -371,69 +413,75 @@ interface paths { cookie?: never; }; /** - * List mailboxes - * @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. - * - * This 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. + * List a workflow's executions + * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listMailboxes"]; + get: operations["v1ListWorkflowExecutions"]; put?: never; /** - * Create a mailbox - * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. - * - * Three consequences worth knowing before you call it: - * - * - **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. - * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. - * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. - * - * Retrying 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. + * Start a workflow for a contact + * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. * - * `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. + * 409 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createMailbox"]; + post: operations["v1StartWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}": { + "/api/v1/workflows/executions/{execution_id}/cancel": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get a mailbox - * @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. + * Cancel a workflow execution + * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - get: operations["getMailbox"]; - put?: never; - post?: never; + post: operations["v1CancelWorkflowExecution"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/{id}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; /** - * Delete a mailbox - * @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. + * Retrieve workflow statistics + * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. * - * Requires an admin of the project. + * The 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - delete: operations["deleteMailbox"]; + get: operations["v1GetWorkflowStats"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords": { + "/api/v1/workflows/{id}/graph": { parameters: { query?: never; header?: never; @@ -441,33 +489,37 @@ interface paths { cookie?: never; }; /** - * List a mailbox's app passwords - * @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. + * Retrieve a workflow's step graph + * @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. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * A 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. + * + * The 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. + * + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listAppPasswords"]; - put?: never; + get: operations["v1GetWorkflowGraph"]; /** - * Create an app password - * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. + * Replace a workflow's step graph + * @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. * - * **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. + * A 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. * - * That 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. + * Refused 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. * - * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * The 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createAppPassword"]; + put: operations["v1ReplaceWorkflowGraph"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords/{passwordId}": { + "/api/v1/workflows/{id}/clone": { parameters: { query?: never; header?: never; @@ -476,52 +528,48 @@ interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an app password - * @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. + * Clone a workflow + * @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. * - * Requires an admin of the project. An API key is refused with 401. + * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeAppPassword"]; + post: operations["v1CloneWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys": { + "/api/v1/workflows/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List API keys for a project - * @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. - * - * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. - */ - get: operations["listApiKeys"]; + get?: never; put?: never; /** - * Create an API key - * @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. + * Pause a workflow and cancel its running executions + * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. * - * **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. + * `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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createApiKey"]; + post: operations["v1PauseWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}": { + "/api/v1/workflows/{id}/resume": { parameters: { query?: never; header?: never; @@ -530,20 +578,22 @@ interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an API key - * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * Resume a paused workflow + * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Refused 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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeApiKey"]; + post: operations["v1ResumeWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}/rotate": { + "/api/v1/emails": { parameters: { query?: never; header?: never; @@ -553,47 +603,55 @@ interface paths { get?: never; put?: never; /** - * Rotate an API key's secret - * @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`). + * Send a transactional email + * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * This 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. + * + * Exactly 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. + * + * `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. + * + * An 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - post: operations["rotateApiKey"]; + post: operations["v1SendEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression": { + "/api/v1/emails/test": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List suppressed emails - * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. - * - * Requires the `suppression:read` scope — View the addresses on your suppression list. - */ - get: operations["listSuppressions"]; + get?: never; put?: never; /** - * Manually add an email to the suppression list - * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * Send a sandbox test email + * @description Prove that sending works — before any domain, DNS record or verification exists. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * The 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. + * + * That 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. + * + * Sandbox 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. + * + * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. */ - post: operations["addSuppression"]; + post: operations["v1SendTestEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression/{email}": { + "/api/emails": { parameters: { query?: never; header?: never; @@ -601,27 +659,27 @@ interface paths { cookie?: never; }; /** - * Check whether an email is suppressed - * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * List emails + * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["checkSuppression"]; + get: operations["listEmails"]; put?: never; - post?: never; /** - * Remove an email from the suppression list - * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * Send a single transactional email + * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["removeSuppression"]; + post: operations["sendEmail"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates": { + "/api/emails/{id}": { parameters: { query?: never; header?: never; @@ -629,59 +687,47 @@ interface paths { cookie?: never; }; /** - * List templates - * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. + * Get a single email + * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. * - * Requires the `templates:read` scope — View your email templates. - */ - get: operations["listTemplates"]; - put?: never; - /** - * Create a template - * @description Create a new email template. The `from` domain must already be verified for the project. + * `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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - post: operations["createTemplate"]; + get: operations["getEmail"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates/{id}": { + "/api/emails/batch": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a template - * @description Requires the `templates:read` scope — View your email templates. - */ - get: operations["getTemplate"]; + get?: never; put?: never; - post?: never; /** - * Delete a template - * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * Send a batch of emails + * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * The 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["deleteTemplate"]; + post: operations["sendEmailBatch"]; + delete?: never; options?: never; head?: never; - /** - * Update a template - * @description Update one or more fields. If `from` changes, the new domain must already be verified. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateTemplate"]; + patch?: never; trace?: never; }; - "/api/track": { + "/api/emails/{id}/schedule": { parameters: { query?: never; header?: never; @@ -690,94 +736,96 @@ interface paths { }; get?: never; put?: never; + post?: never; /** - * Track a custom event for a contact - * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. - * - * Requires the `events:write` scope — Record custom events for your contacts. + * Cancel a scheduled (still-PENDING) email + * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. */ - post: operations["trackEvent"]; - delete?: never; + delete: operations["cancelScheduledEmail"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/users/me/projects": { + "/api/contacts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create a project - * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * List contacts + * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. * - * Preconditions 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. + * Requires the `contacts:read` scope — View your contacts and their custom fields. + */ + get: operations["listContacts"]; + put?: never; + /** + * Create a contact + * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. * - * Requires the `projects:write` scope — Create new projects on your account. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["createProject"]; + post: operations["createContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/campaigns": { + "/api/contacts/upsert": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve campaign totals and engagement - * @description Campaign counts plus average open and click rates. - * - * `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. - * - * `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. + * Create or update a contact by email + * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetCampaignAnalytics"]; - put?: never; - post?: never; + post: operations["upsertContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/timeseries": { + "/api/contacts/bulk": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve the daily email time series - * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * Bulk-create contacts + * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. * - * `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. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + post: operations["bulkCreateContacts"]; + /** + * Bulk-delete contacts + * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetAnalyticsTimeseries"]; - put?: never; - post?: never; - delete?: never; + delete: operations["bulkDeleteContacts"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/top-campaigns": { + "/api/contacts/{id}": { parameters: { query?: never; header?: never; @@ -785,23 +833,31 @@ interface paths { cookie?: never; }; /** - * List the best-performing campaigns - * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. - * - * `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. - * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Get a contact + * @description Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListTopCampaigns"]; + get: operations["getContact"]; put?: never; post?: never; - delete?: never; + /** + * Delete a contact + * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + delete: operations["deleteContact"]; options?: never; head?: never; - patch?: never; + /** + * Update a contact + * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + patch: operations["updateContact"]; trace?: never; }; - "/api/v1/campaigns": { + "/api/v1/contacts": { parameters: { query?: never; header?: never; @@ -809,33 +865,29 @@ interface paths { cookie?: never; }; /** - * List campaigns - * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * List contacts + * @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. * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * A 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. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListCampaigns"]; + get: operations["v1ListContacts"]; put?: never; /** - * Create a campaign - * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. - * - * The `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. - * - * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * Create a contact + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateCampaign"]; + post: operations["v1CreateContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}": { + "/api/v1/contacts/{id}": { parameters: { query?: never; header?: never; @@ -843,35 +895,37 @@ interface paths { cookie?: never; }; /** - * Retrieve a campaign - * @description Fetch one campaign, including its materialized delivery counters. + * Retrieve a contact + * @description Fetch one contact by id. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1GetCampaign"]; + get: operations["v1GetContact"]; put?: never; post?: never; /** - * Delete a campaign - * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. + * Delete a contact + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteCampaign"]; + delete: operations["v1DeleteContact"]; options?: never; head?: never; /** - * Update a campaign - * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * Update a contact + * @description Partial update. Omitted fields are left alone. * - * Changing `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. + * `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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * `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. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - patch: operations["v1UpdateCampaign"]; + patch: operations["v1UpdateContact"]; trace?: never; }; - "/api/v1/campaigns/{id}/cancel": { + "/api/lists/{id}/subscribe": { parameters: { query?: never; header?: never; @@ -881,21 +935,23 @@ interface paths { get?: never; put?: never; /** - * Cancel a campaign - * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. + * Subscribe a contact to a list + * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. * - * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * **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. + * + * `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. */ - post: operations["v1CancelCampaign"]; + post: operations["subscribeToList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/pause": { + "/api/lists/{id}/unsubscribe": { parameters: { query?: never; header?: never; @@ -905,67 +961,53 @@ interface paths { get?: never; put?: never; /** - * Pause a sending campaign - * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. + * Unsubscribe a contact from a list + * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. */ - post: operations["v1PauseCampaign"]; + post: operations["unsubscribeFromList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/resume": { + "/api/v1/lists": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Resume a paused campaign - * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. + * List subscriber lists + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * `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. + * + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - post: operations["v1ResumeCampaign"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/campaigns/{id}/send": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; + get: operations["v1ListLists"]; put?: never; /** - * Send or schedule a campaign - * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. + * Create a subscriber list + * @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`. * - * **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. + * **`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. * - * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. * - * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["v1SendCampaign"]; + post: operations["v1CreateList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/stats": { + "/api/v1/lists/{id}": { parameters: { query?: never; header?: never; @@ -973,81 +1015,101 @@ interface paths { cookie?: never; }; /** - * Retrieve campaign statistics - * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. - * - * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. + * Retrieve a subscriber list + * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - get: operations["v1GetCampaignStats"]; + get: operations["v1GetList"]; put?: never; post?: never; - delete?: never; + /** + * Delete a subscriber list + * @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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + delete: operations["v1DeleteList"]; options?: never; head?: never; - patch?: never; + /** + * Update a subscriber list + * @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. + * + * **`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. + * + * Turning `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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + patch: operations["v1UpdateList"]; trace?: never; }; - "/api/v1/emails": { + "/api/domains": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a transactional email - * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. - * - * This 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. - * - * Exactly 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. - * - * `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. + * List sending domains + * @description List all domains for the authenticated project. * - * An 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. + * Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["listDomains"]; + put?: never; + /** + * Add a sending domain + * @description Register a new domain with SES and persist its DKIM tokens. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1SendEmail"]; + post: operations["addDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails/test": { + "/api/domains/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a sandbox test email - * @description Prove that sending works — before any domain, DNS record or verification exists. + * Get a sending domain + * @description Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["getDomain"]; + put?: never; + post?: never; + /** + * Remove a sending domain + * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. * - * The 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. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + delete: operations["deleteDomain"]; + options?: never; + head?: never; + /** + * Assign a sending identity to a stream + * @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. * - * That 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. + * Streams 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. * - * Sandbox 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. + * At 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. * - * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1SendTestEmail"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["assignDomainStream"]; trace?: never; }; - "/api/v1/events": { + "/api/domains/{id}/verify": { parameters: { query?: never; header?: never; @@ -1055,59 +1117,53 @@ interface paths { cookie?: never; }; /** - * List events - * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. - * - * A 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. + * Read SES verification status + * @description Read the current SES verification status without forcing a refresh. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1ListEvents"]; + get: operations["getDomainVerification"]; put?: never; /** - * Record an event - * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. - * - * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. - * - * Reserved 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. - * - * This 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. - * - * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * Trigger SES verification + * @description Force a refresh of the domain's SES verification status. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1TrackEvent"]; + post: operations["verifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events/names": { + "/api/domains/{id}/dodomain-session": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List event names - * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * Start guided DNS setup + * @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. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * The 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. + * + * `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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1ListEventNames"]; - put?: never; - post?: never; + post: operations["startDomainSetup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events/stats": { + "/api/v1/domains": { parameters: { query?: never; header?: never; @@ -1115,23 +1171,35 @@ interface paths { cookie?: never; }; /** - * Retrieve event counts - * @description Per-name event counts over a bounded window, most frequent first. + * List sending domains + * @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. * - * The 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. + * `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. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetEventStats"]; + get: operations["v1ListDomains"]; put?: never; - post?: never; + /** + * Add a sending domain + * @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. + * + * `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. + * + * `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. + * + * A 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["v1CreateDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/projects": { + "/api/v1/domains/{id}": { parameters: { query?: never; header?: never; @@ -1139,57 +1207,55 @@ interface paths { cookie?: never; }; /** - * Retrieve the authenticated project - * @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. - * - * `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). - * - * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * Retrieve a sending domain + * @description Fetch one sending domain by id. * - * Requires the `projects:read` scope — View your projects and their settings. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetProject"]; + get: operations["v1GetDomain"]; put?: never; post?: never; - delete?: never; + /** + * Remove a sending domain + * @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. + * + * The 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + delete: operations["v1DeleteDomain"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments": { + "/api/v1/domains/{id}/verify": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List segments - * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * `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. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. - */ - get: operations["v1ListSegments"]; + get?: never; put?: never; /** - * Create a segment - * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. + * Refresh a sending domain's verification state + * @description Re-read this domain's state from SES and DNS and return the refreshed document. * - * A `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. + * This 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. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1CreateSegment"]; + post: operations["v1VerifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments/{id}": { + "/api/templates": { parameters: { query?: never; header?: never; @@ -1197,35 +1263,27 @@ interface paths { cookie?: never; }; /** - * Retrieve a segment - * @description Fetch one segment, including its saved `condition` and materialized `member_count`. + * List templates + * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetSegment"]; + get: operations["listTemplates"]; put?: never; - post?: never; /** - * Delete a segment - * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. + * Create a template + * @description Create a new email template. The `from` domain must already be verified for the project. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["v1DeleteSegment"]; + post: operations["createTemplate"]; + delete?: never; options?: never; head?: never; - /** - * Update a segment - * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. - * - * `type` is not accepted here — see the create operation. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. - */ - patch: operations["v1UpdateSegment"]; + patch?: never; trace?: never; }; - "/api/v1/segments/{id}/contacts": { + "/api/templates/{id}": { parameters: { query?: never; header?: never; @@ -1233,23 +1291,31 @@ interface paths { cookie?: never; }; /** - * List the contacts in a segment - * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. - * - * Cursors 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. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Get a template + * @description Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListSegmentContacts"]; + get: operations["getTemplate"]; put?: never; post?: never; - delete?: never; + /** + * Delete a template + * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + delete: operations["deleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Update one or more fields. If `from` changes, the new domain must already be verified. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateTemplate"]; trace?: never; }; - "/api/v1/usage": { + "/api/v1/templates": { parameters: { query?: never; header?: never; @@ -1257,28 +1323,31 @@ interface paths { cookie?: never; }; /** - * Retrieve current usage and limits - * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. - * - * Every 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. + * List templates + * @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. * - * Two caveats worth reading before you alert on these numbers: + * `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. * - * - 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. - * - `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`. + * A 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. * - * Requires the `usage:read` scope — View your usage totals and billing limits. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetUsage"]; + get: operations["v1ListTemplates"]; put?: never; - post?: never; + /** + * Create a template + * @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. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + post: operations["v1CreateTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows": { + "/api/v1/templates/{id}": { parameters: { query?: never; header?: never; @@ -1286,51 +1355,65 @@ interface paths { cookie?: never; }; /** - * List workflows - * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * Retrieve a template + * @description Fetch one template by id. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListWorkflows"]; + get: operations["v1GetTemplate"]; put?: never; + post?: never; /** - * Create a workflow - * @description Creates an event-triggered workflow with a single trigger step. The rest of the graph (emails, delays, conditions) is built in the dashboard, so a workflow is created disabled and stays inert until it has steps to run. + * Delete a template + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1CreateWorkflow"]; - delete?: never; + delete: operations["v1DeleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Partial update. Omitted fields are left alone. + * + * Changing `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. + * + * A `from` supplied here is verified before anything is written, on the same terms as create. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["v1UpdateTemplate"]; trace?: never; }; - "/api/v1/workflows/executions/{execution_id}/cancel": { + "/api/snippets": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List snippets + * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. + * + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listSnippets"]; put?: never; /** - * Cancel a workflow execution - * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. + * Create a snippet + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1CancelWorkflowExecution"]; + post: operations["createSnippet"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}": { + "/api/snippets/{id}": { parameters: { query?: never; header?: never; @@ -1338,35 +1421,29 @@ interface paths { cookie?: never; }; /** - * Retrieve a workflow - * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Get a snippet + * @description Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetWorkflow"]; + get: operations["getSnippet"]; put?: never; post?: never; /** - * Delete a workflow - * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. + * Delete a snippet + * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["v1DeleteWorkflow"]; + delete: operations["deleteSnippet"]; options?: never; head?: never; /** - * Update a workflow - * @description Sparse update — omitted fields are left unchanged. - * - * Two 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. - * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Update a snippet + * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - patch: operations["v1UpdateWorkflow"]; + patch: operations["updateSnippet"]; trace?: never; }; - "/api/v1/workflows/{id}/executions": { + "/api/webhooks": { parameters: { query?: never; header?: never; @@ -1374,29 +1451,27 @@ interface paths { cookie?: never; }; /** - * List a workflow's executions - * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * List user webhooks + * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1ListWorkflowExecutions"]; + get: operations["listWebhooks"]; put?: never; /** - * Start a workflow for a contact - * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. - * - * 409 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. + * Create a webhook + * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1StartWorkflowExecution"]; + post: operations["createWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/stats": { + "/api/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -1404,21 +1479,29 @@ interface paths { cookie?: never; }; /** - * Retrieve workflow statistics - * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Get a webhook + * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetWorkflowStats"]; + get: operations["getWebhook"]; put?: never; post?: never; - delete?: never; + /** + * Delete a webhook + * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + delete: operations["deleteWebhook"]; options?: never; head?: never; - patch?: never; + /** + * Update a webhook + * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + patch: operations["updateWebhook"]; trace?: never; }; - "/api/verify": { + "/api/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; @@ -1428,17 +1511,19 @@ interface paths { get?: never; put?: never; /** - * Validate an email address - * @description Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier. + * Rotate the webhook signing secret + * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["verifyEmailAddress"]; + post: operations["rotateWebhookSecret"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks": { + "/api/webhooks/{id}/calls": { parameters: { query?: never; header?: never; @@ -1446,27 +1531,53 @@ interface paths { cookie?: never; }; /** - * List user webhooks - * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). + * List recent webhook calls + * @description Cursor-paginated list of recent delivery attempts for a single webhook. * * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["listWebhooks"]; + get: operations["listWebhookCalls"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List webhooks + * @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. + * + * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + */ + get: operations["v1ListWebhooks"]; put?: never; /** * Create a webhook - * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. + * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. + * + * The 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. * * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["createWebhook"]; + post: operations["v1CreateWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}": { + "/api/v1/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -1474,51 +1585,93 @@ interface paths { cookie?: never; }; /** - * Get a webhook - * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Retrieve a webhook + * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["getWebhook"]; + get: operations["v1GetWebhook"]; put?: never; post?: never; /** * Delete a webhook - * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * @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. * * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - delete: operations["deleteWebhook"]; + delete: operations["v1DeleteWebhook"]; options?: never; head?: never; /** * Update a webhook - * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * @description Partial update. 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 part of this response. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - patch: operations["updateWebhook"]; + patch: operations["v1UpdateWebhook"]; trace?: never; }; - "/api/webhooks/{id}/calls": { + "/api/v1/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List recent webhook calls - * @description Cursor-paginated list of recent delivery attempts for a single webhook. + * Rotate a webhook signing secret + * @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. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Rotation 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. + * + * `url`, `event_types` and `status` are unchanged. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["listWebhookCalls"]; + post: operations["v1RotateWebhookSecret"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{id}/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List API keys for a project + * @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. + * + * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + */ + get: operations["listApiKeys"]; put?: never; - post?: never; + /** + * Create an API key + * @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. + * + * **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. + * + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + */ + post: operations["createApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}/rotate-secret": { + "/api/projects/{id}/api-keys/{keyId}/rotate": { parameters: { query?: never; header?: never; @@ -1528,1624 +1681,8443 @@ interface paths { get?: never; put?: never; /** - * Rotate the webhook signing secret - * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * Rotate an API key's secret + * @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`). * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["rotateWebhookSecret"]; + post: operations["rotateApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; -} -interface components { - schemas: { - /** @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. */ - AddDomainBody: { - domain: string; - /** Format: uuid */ - projectId?: string; - /** - * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. - * @enum {string} - */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; - /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ - AddSuppression: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; - /** @description Campaign counters and engagement over the window. */ - AnalyticsCampaignStatsV1: { - /** @description Campaigns in DRAFT or SCHEDULED. */ - active: number; - average_click_rate: number; - /** @description Percentage, one decimal place. */ - average_open_rate: number; - completed: number; - total: number; - window: components["schemas"]["AnalyticsWindowV1"]; + "/api/projects/{id}/api-keys/{keyId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ - AnalyticsTimeseriesV1: { - data: { - bounces: number; - clicks: number; - /** Format: date-time */ - date: string; - delivered: number; - emails: number; - opens: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + get?: never; + put?: never; + post?: never; + /** + * Revoke an API key + * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + */ + delete: operations["revokeApiKey"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/suppression": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Sent campaigns ranked by open rate. */ - AnalyticsTopCampaignsV1: { - data: { - click_rate: number; - clicked: number; - /** Format: uuid */ - id: string; - open_rate: number; - opened: number; - sent: number; - subject: string; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** + * List suppressed emails + * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["listSuppressions"]; + put?: never; + /** + * Manually add an email to the suppression list + * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + post: operations["addSuppression"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/suppression/{email}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check whether an email is suppressed + * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["checkSuppression"]; + put?: never; + post?: never; + /** + * Remove an email from the suppression list + * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["removeSuppression"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List suppressed addresses + * @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. + * + * A 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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1ListSuppressions"]; + put?: never; + /** + * Suppress an address + * @description Add an address to this project's suppression list, so no further send reaches it. + * + * Idempotent: 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. + * + * `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. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + post: operations["v1CreateSuppression"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions/{email}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check whether an address is suppressed + * @description Fetch the suppression record for one address. The path parameter is the address itself, URL-encoded. + * + * An 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. + * + * A `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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1GetSuppression"]; + put?: never; + post?: never; + /** + * Remove an address from the suppression list + * @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. + * + * It 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. + * + * Idempotent: an address that was never suppressed answers `200` too, because "not on the list" is the state you asked for. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["v1DeleteSuppression"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/track": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Track a custom event for a contact + * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["trackEvent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List events + * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. + * + * A 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1ListEvents"]; + put?: never; + /** + * Record an event + * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. + * + * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * + * Reserved 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. + * + * This 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. + * + * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["v1TrackEvent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events/names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List event names + * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1ListEventNames"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve event counts + * @description Per-name event counts over a bounded window, most frequent first. + * + * The 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1GetEventStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/timeseries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve the daily email time series + * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1GetAnalyticsTimeseries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve campaign totals and engagement + * @description Campaign counts plus average open and click rates. + * + * `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. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1GetCampaignAnalytics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/top-campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the best-performing campaigns + * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1ListTopCampaigns"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/diagnose": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Diagnose why mail from a domain is not arriving + * @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. + * + * Everything 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. + * + * `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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1DiagnoseDeliverability"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Delivery outcomes per recipient domain + * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. + * + * This 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. + * + * The 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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1ListRecipientDomainStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/dmarc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * DMARC aggregate reports for your domains + * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * + * The 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. + * + * `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. + * + * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1ListDmarcReports"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve current usage and limits + * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. + * + * Every 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. + * + * Two caveats worth reading before you alert on these numbers: + * + * - 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. + * - `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`. + * + * Requires the `usage:read` scope — View your usage totals and billing limits. + */ + get: operations["v1GetUsage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve the authenticated project + * @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. + * + * `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). + * + * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * + * Requires the `projects:read` scope — View your projects and their settings. + */ + get: operations["v1GetProject"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List mailboxes + * @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. + * + * This 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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["listMailboxes"]; + put?: never; + /** + * Create a mailbox + * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. + * + * Three consequences worth knowing before you call it: + * + * - **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. + * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. + * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * + * Retrying 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. + * + * `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. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + post: operations["createMailbox"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a mailbox + * @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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["getMailbox"]; + put?: never; + post?: never; + /** + * Delete a mailbox + * @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. + * + * Requires an admin of the project. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + delete: operations["deleteMailbox"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Draft a message with AI + * @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. + * + * **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. + * + * That 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. + * + * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * + * Drafting is capped at 120 requests per hour per project. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + post: operations["draftMailboxMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send a message from a mailbox + * @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. + * + * **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. + * + * **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. + * + * Bcc 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. + * + * Refusals worth handling by name: + * + * - `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. + * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. + * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. + * - `429` — a mailbox may send 60 messages an hour through this endpoint. + * + * The message is stored as a new conversation on the mailbox, so the reply threads onto it. + * + * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. + */ + post: operations["sendMailboxMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/app-passwords": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a mailbox's app passwords + * @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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["listAppPasswords"]; + put?: never; + /** + * Create an app password + * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. + * + * **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. + * + * That 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. + * + * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + post: operations["createAppPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/app-passwords/{passwordId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke an app password + * @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. + * + * Requires an admin of the project. An API key is refused with 401. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + delete: operations["revokeAppPassword"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a project + * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * + * Preconditions 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. + * + * Requires the `projects:write` scope — Create new projects on your account. + */ + post: operations["createProject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate an email address + * @description Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier. + */ + post: operations["verifyEmailAddress"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List topics + * @description The subjects this project mails about, cursor-paginated and newest first. + * + * Archived 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. + * + * `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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1ListTopics"]; + put?: never; + /** + * Create a topic + * @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. + * + * `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`. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + post: operations["v1CreateTopic"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve a topic + * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1GetTopic"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update a topic + * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + patch: operations["v1UpdateTopic"]; + trace?: never; + }; + "/api/v1/topics/{id}/subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Subscribe or unsubscribe a contact from a topic + * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. + * + * `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. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + post: operations["v1SetTopicSubscription"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/contacts/{id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a contact's topic preferences + * @description Everything this contact has said they want, as the send path reads it. + * + * `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. + * + * The 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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1GetContactTopicPreferences"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/email-validations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate a batch of email addresses + * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. + * + * The 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. + * + * `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. + * + * The 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + */ + post: operations["v1ValidateEmails"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/lists/{id}/validation-runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate every address on a list + * @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. + * + * This 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`. + * + * A 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + */ + post: operations["v1StartListValidationRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/validation-runs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve a validation run + * @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. + * + * There 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. + * + * Requires the `validation:read` scope — View your email validation runs and their results. + */ + get: operations["v1GetValidationRun"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/validation-runs/{id}/results": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a validation run's results + * @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. + * + * No 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. + * + * `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. + * + * Requires the `validation:read` scope — View your email validation runs and their results. + */ + get: operations["v1ListValidationRunResults"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +interface components { + schemas: { + /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ + Error: { + /** @enum {boolean} */ + success?: false; + error: { + message: string; + code: string; + details?: { + errors: unknown[]; + }; + }; + }; + /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ + Problem: { + /** + * Format: uri + * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + */ + type: string; + /** @description Short, stable summary — the same for every occurrence of a `type`. */ + title: string; + /** @description HTTP status code, repeated in the body. */ + status: number; + /** @description Explanation specific to this occurrence. */ + detail?: string; + /** @description Request path the failure occurred on. */ + instance?: string; + /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ + code: string; + /** @description Correlation id — quote it in support requests. */ + request_id?: string; + /** @description Field-level failures. Present on 422 `validation_error` responses. */ + errors?: { + /** @description RFC 6901 JSON Pointer to the offending field. */ + pointer: string; + code: string; + message: string; + }[]; + }; + /** @description Bare success envelope with no payload. */ + SuccessEmpty: { + /** @enum {boolean} */ + success: true; + }; + /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ + IdResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + id: string; + }; + }; + /** @description A subscriber/contact within a project. */ + Contact: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: email */ + email: string; + subscribed: boolean; + customFields?: { + [key: string]: unknown; + } | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Contact"][]; + total: number; + /** @description Cursor for the next page, or null on the last page. */ + nextCursor: string | null; + hasMore: boolean; + }; + }; + /** @description A reusable email template. */ + Template: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + description?: string | null; + subject: string; + body: string; + /** Format: email */ + from: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** @enum {string} */ + emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of templates. */ + TemplateListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Template"][]; + total: number; + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + hasMore: boolean; + }; + }; + /** @description A reusable fragment of template markup. */ + Snippet: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** @description The literal identifier a template includes with `{{> name}}`. */ + name: string; + description?: string | null; + /** @description Template markup. Values it interpolates are escaped like any other. */ + body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of snippets. */ + SnippetListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Snippet"][]; + total: number; + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + hasMore: boolean; + }; + }; + /** + * @description Delivery lifecycle of the message. Engagement is reported separately. + * @enum {string} + */ + EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ + Domain: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + verified: boolean; + region?: string | null; + dkim?: { + type: string; + name: string; + value: string; + }[]; + /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + /** + * @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 {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @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). */ + streamDefault?: boolean; + /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ + defaultFromAddress?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description List of all domains for the auth'd project. */ + DomainListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Domain"][]; + }; + /** @description Outcome of a verification check against SES. */ + DomainVerificationStatus: { + verified: boolean; + mxRecords?: string[]; + dkim?: { + type: string; + name: string; + value: string; + }[]; + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + }; + /** @description A sent (or queued) transactional email. */ + Email: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + from: string; + to: string; + subject: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description First open, or null. + */ + openedAt: string | null; + /** + * Format: date-time + * @description First click, or null. + */ + clickedAt: string | null; + /** + * Format: date-time + * @description Spam complaint, or null. + */ + complainedAt: string | null; + /** @description Total opens recorded. */ + opens: number; + /** @description Total clicks recorded. */ + clicks: number; + tags: string[]; + error?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description One transition in a message's delivery history. */ + EmailEvent: { + /** Format: uuid */ + id: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description A transactional email together with its delivery history. */ + EmailWithEvents: components["schemas"]["Email"] & { + /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ + events: components["schemas"]["EmailEvent"][]; + }; + /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ + SendEmailRecipientResult: { + contact: { + /** Format: uuid */ + id: string; + /** Format: email */ + email: string; + }; + /** Format: uuid */ + email: string; + }; + /** @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`. */ + SendEmailData: { + emails: components["schemas"]["SendEmailRecipientResult"][]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @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. */ + SendEmailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["SendEmailData"]; + }; + /** @description Per-row result in a batch send response. */ + BatchEntryResult: { + index: number; + /** @enum {string} */ + status: "ok" | "error"; + data?: components["schemas"]["SendEmailData"]; + error?: { + message: string; + code: string; + }; + }; + /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ + BatchSendResponse: { + success: boolean; + data: components["schemas"]["BatchEntryResult"][]; + }; + /** @description Batch send wrapper. Up to 100 entries. */ + BatchSendBody: { + emails: components["schemas"]["SendEmail"][]; + }; + /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ + SendEmail: { + to: string | { + name?: string; + /** Format: email */ + email: string; + } | (string | { + name?: string; + /** Format: email */ + email: string; + })[]; + subject?: string; + body?: string; + /** Format: uuid */ + template?: string; + subscribed?: boolean; + name?: string; + from?: string | { + name?: string; + /** Format: email */ + email: string; + }; + /** Format: email */ + reply?: string; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + attachments?: { + filename: string; + content: string; + contentType: string; + contentId?: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + }[]; + tags?: string[]; + cc?: string[]; + bcc?: string[]; + }; + /** @description Bulk create up to 1000 contacts. */ + ContactBulkCreateBody: { + contacts: components["schemas"]["CreateContact"][]; + }; + /** @description Body for POST /api/contacts and /api/contacts/upsert. */ + CreateContact: { + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + customFields?: { + [key: string]: unknown; + }; + }; + /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ + ContactBulkDeleteBody: { + ids?: string[]; + emails?: string[]; + }; + /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ + UpdateContactBody: { + subscribed?: boolean; + customFields?: { + [key: string]: unknown; + }; + }; + /** @description A single suppressed-email record. */ + Suppression: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: email */ + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + }; + /** @description Cursor-paginated list of suppressions. */ + SuppressionListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Suppression"][]; + nextCursor?: string | null; + cursor?: string | null; + hasMore?: boolean; + }; + /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ + SuppressionCheckResponse: { + suppressed: boolean; + /** @enum {string} */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt?: string; + }; + /** @description A user-managed outbound webhook. */ + Webhook: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: uri */ + url: string; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + consecutiveFailures: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + disabledAt?: string | null; + lastFour?: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ + WebhookCreateResponse: { + /** @enum {boolean} */ + success: true; + /** @description A user-managed outbound webhook. */ + data: components["schemas"]["Webhook"] & { + /** @description Plaintext shared secret. Returned ONCE on create. */ + secret: string; + }; + }; + /** @description List of webhooks for the auth'd project. */ + WebhookListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Webhook"][]; + }; + /** @description Single webhook (no secret). */ + WebhookGetResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Webhook"]; + }; + /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ + WebhookRotateSecretResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + id: string; + /** @description New plaintext shared secret. */ + secret: string; + }; + }; + /** @description An attempted webhook delivery. */ + WebhookCall: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + webhookId: string; + eventType: string; + payload: { + [key: string]: unknown; + }; + /** @enum {string} */ + status: "PENDING" | "SUCCESS" | "FAILED"; + attempt: number; + responseStatus?: number | null; + responseBody?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + }; + /** @description Cursor-paginated list of recent calls for a single webhook. */ + WebhookCallsListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["WebhookCall"][]; + nextCursor?: string | null; + cursor?: string | null; + hasMore?: boolean; + }; + /** @description Response from POST /api/track. */ + TrackEventResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + contact: string; + /** Format: uuid */ + event: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + }; + /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ + VerifyEmailResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: email */ + email: string; + valid: boolean; + reason?: string; + } & { + [key: string]: unknown; + }; + }; + /** @description Cursor-paginated list of emails. */ + EmailListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Email"][]; + nextCursor?: string | null; + }; + /** @description A single email. */ + EmailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Email"]; + }; + /** @description One email and its delivery history. */ + EmailDetailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["EmailWithEvents"]; + }; + /** @description Result of a list-subscribe call. */ + ListSubscribeResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + membershipId: string; + /** @enum {string} */ + status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; + /** @description True when the membership row did not exist before this call. */ + created: boolean; + /** + * @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 {string|null} + */ + previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; + /** @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. */ + confirmToken?: string; + }; + }; + /** @description Echoes the address that was unsubscribed. */ + ListUnsubscribeResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: email */ + email: string; + }; + }; + /** @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. */ + ApiKey: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ + lastFour: string; + /** + * @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 {string} + */ + legacyGrantPreset: "FULL" | "SENDING_ONLY"; + /** + * @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 {string} + */ + mode: "LIVE" | "TEST"; + /** @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. */ + scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + /** Format: uuid */ + domainId: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + lastUsedAt: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. + */ + revokedAt: string | null; + }; + /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ + ApiKeyListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["ApiKey"][]; + }; + ProjectRecord: { + /** Format: uuid */ + id: string; + name: string; + disabled: boolean; + disabledReason: string | null; + /** @description Local-part of the sandbox quick-start sender; null until first derived. */ + sandboxHandle: string | null; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + billingLimitWorkflows: number | null; + billingLimitCampaigns: number | null; + billingLimitTransactional: number | null; + billingLimitInbound: number | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + sesRegion: string | null; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; + organizationId: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of campaigns. */ + CampaignV1List: { + data: components["schemas"]["CampaignV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A campaign as exposed on the v1 API. */ + CampaignV1: { + /** Format: uuid */ + id: string; + name: string; + /** @enum {string} */ + status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; + subject: string; + /** @enum {string} */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + /** Format: uuid */ + list_id: string | null; + /** Format: uuid */ + topic_id: string | null; + /** Format: date-time */ + scheduled_at: string | null; + /** Format: date-time */ + sent_at: string | null; + /** Format: date-time */ + created_at: string; + stats: { + total_recipients: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + }; + }; + /** @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`. */ + CampaignV1Create: { + name: string; + description?: string; + subject: string; + body: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** + * @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 {string} + */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** Format: uuid */ + segment_id?: string; + /** + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + */ + list_id?: string; + /** + * Format: uuid + * @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. + */ + topic_id?: string | null; + }; + /** @description A filter condition: one or more groups combined with `logic`. */ + FilterConditionV1: { + /** @enum {string} */ + logic: "AND" | "OR"; + groups: components["schemas"]["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. */ + FilterGroupV1: { + filters: components["schemas"]["SegmentFilterV1"][]; + conditions?: components["schemas"]["FilterConditionV1"]; + }; + /** @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). */ + SegmentFilterV1: { + field: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; + value?: unknown; + /** @enum {string} */ + unit?: "days" | "hours" | "minutes"; + }; + /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ + CampaignV1Update: { + name?: string; + description?: string; + subject?: string; + body?: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from?: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** @enum {string} */ + audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** Format: uuid */ + segment_id?: string; + /** + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + */ + list_id?: string; + /** + * Format: uuid + * @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. + */ + topic_id?: string | null; + }; + /** @description Acknowledgement that a campaign was deleted. */ + CampaignV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/v1/campaigns/{id}/send. */ + CampaignV1Send: { + /** + * Format: date-time + * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. + */ + scheduled_for?: string; + }; + /** @description Materialized delivery and engagement counters for one campaign. */ + CampaignV1Stats: { + total_recipients: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + open_rate: number; + click_rate: number; + bounce_rate: number; + delivery_rate: number; + }; + /** @description Cursor-paginated list of a campaign's failed sends. */ + CampaignV1FailureList: { + data: components["schemas"]["CampaignV1Failure"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + /** @description Every FAILED row on this campaign, not just this page. */ + total: number; + }; + /** @description A campaign recipient whose send did not complete. */ + CampaignV1Failure: { + /** + * Format: uuid + * @description Ledger row id. Pass the last one as `after` to page. + */ + id: string; + /** Format: uuid */ + contact_id: string; + /** @description The recipient the send was for. */ + email: string; + reason: string | null; + /** Format: date-time */ + failed_at: string; + }; + /** @description Acknowledgement that a retry of a campaign's failed sends began. */ + CampaignV1RetryFailed: { + /** Format: uuid */ + id: string; + /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ + queued: number; + }; + /** @description Cursor-paginated list of segments. */ + SegmentV1List: { + data: components["schemas"]["SegmentV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A segment as exposed on the v1 API. */ + SegmentV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + /** @enum {string} */ + type: "DYNAMIC" | "STATIC"; + condition: components["schemas"]["FilterConditionV1"] | null; + track_membership: boolean; + member_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ + SegmentV1Create: { + name: string; + description?: string; + /** + * @default DYNAMIC + * @enum {string} + */ + type: "DYNAMIC" | "STATIC"; + condition?: components["schemas"]["FilterConditionV1"]; + /** + * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. + * @default false + */ + track_membership: boolean; + }; + /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ + SegmentV1Update: { + name?: string; + description?: string; + condition?: components["schemas"]["FilterConditionV1"]; + track_membership?: boolean; + }; + /** @description Acknowledgement that a segment was deleted. */ + SegmentV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Cursor-paginated list of the contacts belonging to a segment. */ + SegmentContactV1List: { + data: components["schemas"]["SegmentContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact belonging to a segment. */ + SegmentContactV1: { + /** Format: uuid */ + id: string; + email: string; + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: { + [key: string]: unknown; + }; + /** Format: date-time */ + created_at: string; + }; + /** @description Cursor-paginated list of workflows. */ + WorkflowV1List: { + data: components["schemas"]["WorkflowV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description An automation workflow as exposed on the v1 API. */ + WorkflowV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + enabled: boolean; + /** @enum {string} */ + trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ + event_name: string | null; + allow_reentry: boolean; + max_executions_per_hour: number | null; + /** @description Incremented on every structural (step/transition) change. */ + version: number; + /** @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. */ + step_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/workflows. */ + WorkflowCreateV1: { + name: string; + description?: string; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; + /** @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. */ + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ + enabled?: boolean; + allow_reentry?: boolean; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + }; + /** + * @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 {string} + */ + WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description One step of a linear workflow sequence. */ + WorkflowSequenceStepV1: { + type: components["schemas"]["WorkflowSequenceStepTypeV1"]; + /** @description Human-readable label, e.g. `Day 0: welcome`. */ + name: string; + /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @description For `SEND_EMAIL`: a template in this project. + */ + template_id?: string; + }; + /** + * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @enum {string} + */ + WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @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. */ + WorkflowUpdateV1: { + name?: string; + description?: string; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + enabled?: boolean; + allow_reentry?: boolean; + /** @description Per-workflow start rate cap. `null` removes the cap. */ + max_executions_per_hour?: number | null; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + }; + /** @description Confirmation that a workflow was deleted. */ + WorkflowDeletedV1: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Cursor-paginated list of workflow executions, newest first. */ + WorkflowExecutionV1List: { + data: components["schemas"]["WorkflowExecutionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One contact's run through a workflow. */ + WorkflowExecutionV1: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + workflow_id: string; + /** Format: uuid */ + contact_id: string; + /** @enum {string} */ + status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + /** Format: uuid */ + current_step_id: string | null; + exit_reason: string | null; + /** Format: date-time */ + started_at: string; + /** Format: date-time */ + completed_at: string | null; + }; + /** @description Body for POST /api/v1/workflows/{id}/executions. */ + WorkflowExecutionStartV1: { + /** + * Format: uuid + * @description Contact to enter the workflow. Must belong to this project. + */ + contact_id: string; + /** @description Extra variables merged into the contact's data for this run. */ + context?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Execution, email and conversion totals for one workflow. */ + WorkflowStatsV1: { + /** Format: uuid */ + workflow_id: string; + name: string; + enabled: boolean; + trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + /** @description Steps in the workflow's graph, trigger step included. */ + step_count: number; + total: number; + /** @description Execution counts keyed by status; a status with no executions is absent. */ + by_status: { + [key: string]: number; + }; + /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ + completion_rate: number | null; + avg_duration_ms: number | null; + emails: { + sent: number; + opened: number; + clicked: number; + }; + conversions: { + /** Format: uuid */ + goal_id: string; + name: string; + event_name: string; + count: number; + }[]; + }; + /** @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. */ + WorkflowGraphV1: { + /** Format: uuid */ + workflow_id: string; + /** @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. */ + version: number; + steps: components["schemas"]["WorkflowStepReadV1"][]; + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @description One node of a workflow graph, as read. */ + WorkflowStepReadV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** @enum {string} */ + type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Where this step sits on the editor canvas. */ + WorkflowStepPositionV1: { + x: number; + y: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description One directed edge between two steps. */ + WorkflowTransitionV1: { + /** + * Format: uuid + * @description Caller-chosen on a write, exactly like a step id. + */ + id: string; + /** Format: uuid */ + from_step_id: string; + /** Format: uuid */ + to_step_id: string; + /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ + condition: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** @description Evaluation order among the edges leaving one step; lowest first. */ + priority: number; + }; + /** @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. */ + WorkflowGraphReplaceV1: { + /** @description The complete step set. Exactly one must be a `TRIGGER`. */ + steps: components["schemas"]["WorkflowStepV1"][]; + /** @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. */ + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @description One node of a workflow graph. */ + WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; + /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ + WorkflowTriggerStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "TRIGGER"; + config: { + eventName?: string; + intervalMs?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ + WorkflowSendEmailStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_EMAIL"; + config: { + /** Format: uuid */ + templateId?: string; + subject?: string; + body?: string; + recipient?: { + /** @enum {string} */ + type: "CONTACT" | "CUSTOM"; + /** Format: email */ + customEmail?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ + WorkflowDelayStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "DELAY"; + config: { + amount?: number; + /** @enum {string} */ + unit?: "minutes" | "hours" | "days"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ + WorkflowWaitForEventStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WAIT_FOR_EVENT"; + config: { + eventName?: string; + timeout?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @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. */ + WorkflowConditionStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "CONDITION"; + config: { + /** @enum {string} */ + mode?: "multi"; + field?: string; + /** @enum {string} */ + operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + branches?: ({ + id: string; + name: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + })[]; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Ends the run early and stamps `exit_reason`. */ + WorkflowExitStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "EXIT"; + config: { + reason?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @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. */ + WorkflowWebhookStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WEBHOOK"; + config: { + /** Format: uri */ + url?: string; + /** @enum {string} */ + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + body?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ + WorkflowUpdateContactStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "UPDATE_CONTACT"; + config: { + updates?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + subscribed?: boolean; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ + WorkflowSendAtOptimalTimeStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_AT_OPTIMAL_TIME"; + config: { + /** Format: uuid */ + templateId?: string; + fallbackHour?: number; + maxDelayHours?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ + WorkflowCloneV1: { + /** @description Name for the copy. Defaults to `Copy of `. */ + name?: string; + }; + /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ + WorkflowStateChangeV1: { + workflow: components["schemas"]["WorkflowV1"]; + /** @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). */ + cancelled_executions: number; + }; + /** @description Receipt for a single transactional send. */ + EmailV1: { + /** + * Format: uuid + * @description The Email row this send created. Quote it in support requests. + */ + id: string; + /** + * @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 {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; + /** + * Format: email + * @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. + */ + from: string; + }; + /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ + SendEmailV1: { + subject?: string; + body?: string; + /** Format: uuid */ + template?: string; + subscribed?: boolean; + name?: string; + from?: string | { + name?: string; + /** Format: email */ + email: string; + }; + /** Format: email */ + reply?: string; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + attachments?: { + filename: string; + content: string; + contentType: string; + contentId?: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + }[]; + tags?: string[]; + cc?: string[]; + bcc?: string[]; + /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ + to: string | { + name?: string; + /** Format: email */ + email: string; + }; + }; + /** @description Receipt for a sandbox test send. */ + EmailTestV1: { + /** + * Format: uuid + * @description The Email row this send created. + */ + id: string; + /** + * @description Delivery status at the moment of the response — `PENDING` for a send still queued. + * @enum {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; + /** + * Format: email + * @description This project's sandbox sender — resolved server-side, never from the body. + */ + from: string; + /** + * @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 {boolean} + */ + sandbox: true; + }; + /** @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. */ + SendTestEmailV1: { + /** + * Format: email + * @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. + */ + to?: string; + subject: string; + /** @description HTML body. Merge tags are rendered as on any other send. */ + body: string; + /** @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. */ + from?: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactV1List: { + data: components["schemas"]["ContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact as exposed on the v1 API. */ + ContactV1: { + /** Format: uuid */ + id: string; + email: string; + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/contacts. */ + ContactV1Create: { + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @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. */ + ContactV1Update: { + subscribed?: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description Acknowledgement that a contact was deleted. */ + ContactV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/lists/{id}/subscribe. */ + ListSubscribe: { + /** Format: email */ + email: string; + /** @description Custom fields to upsert onto the contact as part of subscribing. */ + data?: { + [key: string]: unknown; + }; + /** + * @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. + * @default false + */ + allowResubscribe: boolean; + }; + /** @description Body for POST /api/lists/{id}/unsubscribe. */ + ListUnsubscribe: { + /** Format: email */ + email: string; + }; + /** @description Cursor-paginated list of subscriber lists. */ + ListV1List: { + data: components["schemas"]["ListV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A subscriber list as exposed on the v1 API. */ + ListV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + double_opt_in: boolean; + /** Format: uuid */ + confirmation_template_id: string | null; + redirect_url: string | null; + /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ + member_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/lists. */ + ListV1Create: { + name: string; + description?: string | null; + /** + * @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. + * @default false + */ + double_opt_in: boolean; + /** Format: uuid */ + confirmation_template_id?: string | null; + /** + * Format: uri + * @description Where a confirmed contact is sent after following the confirmation link. + */ + redirect_url?: string | null; + }; + /** @description Body for PATCH /api/v1/lists/{id}. */ + ListV1Update: { + name?: string; + description?: string | null; + double_opt_in?: boolean; + /** Format: uuid */ + confirmation_template_id?: string | null; + /** Format: uri */ + redirect_url?: string | null; + }; + /** @description Acknowledgement that a list was deleted. */ + ListV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @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. */ + AddDomainBody: { + /** Format: uuid */ + projectId?: string; + domain: string; + /** + * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. + * @enum {string} + */ + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"]; + /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ + streamDefault?: boolean; + }; + /** + * @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 {string} + */ + SendingStream: "TRANSACTIONAL" | "MARKETING"; + /** @description Body for PATCH /api/domains/{id}. */ + AssignDomainStream: { + /** + * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. + * @enum {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @description Make this the project's default identity for its stream, demoting whichever held it. */ + streamDefault?: boolean; + /** + * Format: email + * @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. + */ + defaultFromAddress?: string | null; + }; + /** @description Cursor-paginated list of sending domains. */ + DomainV1List: { + data: components["schemas"]["DomainV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A sending domain as exposed on the v1 API. */ + DomainV1: { + /** Format: uuid */ + id: string; + domain: string; + verified: boolean; + region: string | null; + stream: components["schemas"]["SendingStream"] & (string | null); + stream_default: boolean; + default_from_address: string | null; + mail_from_domain: string | null; + /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ + mail_from_domain_status: string | null; + dkim_verified: boolean; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/domains. */ + DomainV1Create: { + domain: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"] & unknown; + /** @description Make this the project's default identity for `stream`. Requires `stream`. */ + stream_default?: boolean; + }; + /** @description Acknowledgement that a sending domain was removed. */ + DomainV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/templates. */ + CreateTemplate: { + name: string; + description?: string; + subject: string; + body: string; + /** Format: email */ + from: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Body for PATCH /api/templates/{id}. */ + UpdateTemplate: { + name?: string; + description?: string; + subject?: string; + body?: string; + /** Format: email */ + from?: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** @enum {string} */ + emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Cursor-paginated list of templates. */ + TemplateV1List: { + data: components["schemas"]["TemplateV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description An email template as exposed on the v1 API. */ + TemplateV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + subject: string; + body: string; + from: string; + from_name: string | null; + reply_to: string | null; + /** @enum {string} */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/templates. */ + TemplateV1Create: { + name: string; + description?: string | null; + subject: string; + body: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Body for PATCH /api/v1/templates/{id}. */ + TemplateV1Update: { + name?: string; + description?: string | null; + subject?: string; + body?: string; + /** Format: email */ + from?: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Acknowledgement that a template was deleted. */ + TemplateV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/snippets. */ + CreateSnippet: { + name: string; + description?: string | null; + body: string; + }; + /** @description Body for PATCH /api/snippets/{id}. */ + UpdateSnippet: { + name?: string; + description?: string | null; + body?: string; + }; + /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ + CreateWebhook: { + /** Format: uri */ + url: string; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + }; + /** @description Body for PATCH /api/webhooks/{id}. */ + UpdateWebhook: { + /** Format: uri */ + url?: string; + eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + }; + /** @description Cursor-paginated list of webhook endpoints. */ + WebhookV1List: { + data: components["schemas"]["WebhookV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @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. */ + WebhookV1: { + /** Format: uuid */ + id: string; + url: string; + event_types: string[]; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description A newly created webhook and its one-time signing secret. */ + WebhookV1Created: { + webhook: components["schemas"]["WebhookV1"]; + /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + }; + /** @description Body for POST /api/v1/webhooks. */ + WebhookV1Create: { + /** Format: uri */ + url: string; + event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + }; + /** @description Body for PATCH /api/v1/webhooks/{id}. */ + WebhookV1Update: { + /** Format: uri */ + url?: string; + event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + }; + /** @description Acknowledgement that a webhook was deleted. */ + WebhookV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ + WebhookV1SecretRotated: { + /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + /** + * Format: date-time + * @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. + */ + previous_secret_expires_at: string; + }; + CreateApiKeyBody: { + name: string; + /** @enum {string} */ + legacyGrantPreset?: "FULL" | "SENDING_ONLY"; + /** + * @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 {string} + */ + mode?: "LIVE" | "TEST"; + /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ + scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + /** Format: uuid */ + domainId?: string | null; + }; + /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ + AddSuppression: { + /** Format: email */ + email: string; + /** + * @default MANUAL + * @enum {string} + */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; + /** @description Cursor-paginated list of suppressed addresses. */ + SuppressionV1List: { + data: components["schemas"]["SuppressionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A suppressed address as exposed on the v1 API. */ + SuppressionV1: { + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + source: string; + /** Format: date-time */ + created_at: string; + }; + /** @description Body for POST /api/v1/suppressions. */ + SuppressionV1Create: { + /** Format: email */ + email: string; + /** + * @default MANUAL + * @enum {string} + */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; + /** @description Acknowledgement that an address was un-suppressed. */ + SuppressionV1Deleted: { + email: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/track — record a custom event for a contact. */ + TrackEvent: { + event: string; + /** Format: email */ + email: string; + subscribed?: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + }; + /** @description Cursor-paginated list of events, newest first. */ + EventV1List: { + data: components["schemas"]["EventV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A recorded custom event. */ + EventV1: { + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + contact_id: string | null; + /** Format: uuid */ + email_id: string | null; + /** @description The payload recorded with the event, or null. */ + payload: { + [key: string]: unknown; + } | null; + /** Format: date-time */ + created_at: string; + }; + /** @description Body for POST /api/v1/events. */ + EventTrackV1: { + /** @description Event name, e.g. `user.signup`. */ + name: string; + /** + * Format: uuid + * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. + */ + contact_id?: string; + /** @description Arbitrary event payload. */ + payload?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Every distinct event name in the project, most frequent first. */ + EventNamesV1: { + data: string[]; + }; + /** @description Per-name event counts over the applied window. */ + EventStatsV1: { + data: { + name: string; + count: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; /** @description The time range this response was computed over, after the 90-day clamp. */ AnalyticsWindowV1: { /** Format: date-time */ - from: string; + from: string; + /** Format: date-time */ + to: string; + }; + /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ + AnalyticsTimeseriesV1: { + data: { + /** Format: date-time */ + date: string; + emails: number; + delivered: number; + opens: number; + clicks: number; + bounces: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Campaign counters and engagement over the window. */ + AnalyticsCampaignStatsV1: { + total: number; + /** @description Campaigns in DRAFT or SCHEDULED. */ + active: number; + completed: number; + /** @description Percentage, one decimal place. */ + average_open_rate: number; + average_click_rate: number; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Sent campaigns ranked by open rate. */ + AnalyticsTopCampaignsV1: { + data: { + /** Format: uuid */ + id: string; + subject: string; + sent: number; + opened: number; + clicked: number; + open_rate: number; + click_rate: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @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. */ + DeliverabilityDiagnosisV1: { + domain: string; + address: string | null; + /** Format: date-time */ + checked_at: string; + identity: components["schemas"]["DeliverabilityIdentityV1"]; + suppression: components["schemas"]["DeliverabilitySuppressionV1"]; + recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; + /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ + findings: components["schemas"]["DeliverabilityFindingV1"][]; + }; + /** @description The sending identity's DNS health, as last refreshed. */ + DeliverabilityIdentityV1: { + /** @description Whether this project has a domain record at all. False makes every other field null. */ + registered: boolean; + verified: boolean; + /** + * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. + * @enum {string|null} + */ + dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description SPF alignment for the sending identity. + * @enum {string|null} + */ + spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description The DMARC policy published at `_dmarc.`. + * @enum {string|null} + */ + dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description Inbound receiving only. Null unless the domain has receiving enabled. + * @enum {string|null} + */ + mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + mail_from_domain: string | null; + /** @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. */ + mail_from_domain_status: string | null; + /** + * Format: date-time + * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. + */ + last_checked_at: string | null; + }; + /** @description Null unless the request named an `address`. */ + DeliverabilitySuppressionV1: { + suppressed: boolean; + /** @enum {string|null} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; + /** @enum {string|null} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; + /** Format: date-time */ + suppressed_at: string | null; + } | null; + /** @description Delivery outcomes over the requested window. */ + DeliverabilityRecentDeliveryV1: { + window_days: number; + /** + * @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 {string} + */ + scope: "project"; + sent: number; + delivered: number; + bounced: number; + complained: number; + failed: number; + /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ + bounce_rate: number | null; + complaint_rate: number | null; + }; + /** @description One diagnosed problem, with its fix. */ + DeliverabilityFindingV1: { + /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ + code: string; + severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; + /** @description What is wrong, in one sentence. */ + summary: string; + /** @description What to do about it. */ + remedy: string; + }; + /** + * @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 {string} + */ + DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; + /** @description Cursor-paginated recipient-domain rollup, newest day first. */ + RecipientDomainStatsV1List: { + data: components["schemas"]["RecipientDomainStatsV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Delivery outcomes for one recipient domain on one day. */ + RecipientDomainStatsV1: { + /** @description The recipient's domain, lowercased: the part after the `@`. */ + domain: string; + /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ + day: string; + sent: number; + delivered: number; + bounced: number; + complained: number; + opened: number; + /** + * Format: date-time + * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. + */ + computed_at: string; + }; + /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ + DmarcReportV1List: { + data: components["schemas"]["DmarcReportV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One DMARC aggregate (RUA) report. */ + DmarcReportV1: { + id: string; + /** @description The receiver's own id for this report. */ + report_id: string; + /** @description The reporting receiver, e.g. `google.com`. */ + org_name: string; + /** @description The domain of yours the report is about. */ + policy_domain: string; + /** Format: date-time */ + range_begin: string; + /** Format: date-time */ + range_end: string; + total_count: number; + /** @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. */ + pass_count: number; + fail_count: number; + /** @description Per-sending-source rows, as the receiver reported them. */ + sources: { + source_ip: string; + count: number; + disposition: string; + dkim: string; + spf: string; + header_from: string; + }[]; + /** Format: date-time */ + received_at: string; + }; + /** @description Current email usage against the limits that are actually enforced. */ + UsageV1: { + /** + * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. + * @enum {string} + */ + plan: "free" | "pro" | "custom"; + monthly: { + emails_sent: number; + /** @description Monthly cap on the total. Null when per-category limits govern instead. */ + limit: number | null; + categories: { + transactional: { + emails_sent: number; + limit: number | null; + }; + campaign: { + emails_sent: number; + limit: number | null; + }; + workflow: { + emails_sent: number; + limit: number | null; + }; + inbound: { + emails_sent: number; + limit: number | null; + }; + }; + }; + daily: { + /** @description Today's sends. Null when the counter could not be read. */ + emails_sent: number | null; + limit: number; + /** @enum {string} */ + trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + }; + }; + /** @description The project the presented credential is scoped to. */ + ProjectV1: { + /** Format: uuid */ + id: string; + name: string; + /** @description A disabled project sends nothing; every send is refused. */ + disabled: boolean; + /** @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. */ + sandbox_address: string | null; + /** @description Locked once the first domain is added. */ + ses_region: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; + /** Format: date-time */ + created_at: string; + }; + /** @description A receiving mailbox on one of the project's verified domains. */ + Mailbox: { + /** Format: uuid */ + id: string; + /** + * Format: email + * @description The full mailbox address, e.g. `support@superbooks.io`. + */ + address: string; + displayName: string | null; + /** + * @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 {string} + */ + status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; + /** @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. */ + quotaBytes: number | null; + /** + * Format: uuid + * @description The verified domain this mailbox lives on. + */ + domainId: string; + /** Format: date-time */ + createdAt: string; + }; + /** @description A mailbox plus its IMAP/SMTP connection settings. */ + MailboxDetail: components["schemas"]["Mailbox"] & { + /** @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. */ + settings: { + imap: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + smtp: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + }; + }; + CreateMailboxBody: { + /** + * Format: uuid + * @description Defaults to the project the credential resolves to. Naming a different one is refused. + */ + projectId?: string; + /** + * Format: uuid + * @description A VERIFIED domain belonging to this project. + */ + domainId: string; + /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ + localPart: string; + displayName?: string; + /** @description NOT IMPLEMENTED — sending any value answers 400. */ + quotaBytes?: number; + }; + /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ + DraftMailboxMessage: { + /** @enum {string} */ + mode: "draft" | "rewrite" | "subject"; + brief?: string; + draft?: string; + instruction?: string; + /** @enum {string} */ + tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; + recipientContext?: string; + senderAddress?: string; + }; + /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ + ComposeMailboxMessage: { + to: string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + body: string; + }; + /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ + AppPassword: { + /** Format: uuid */ + id: string; + /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ + name: string; + /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ + scopes: ("imap" | "smtp")[]; + /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ + lastFour: string; + /** + * Format: date-time + * @description Null until a mail client has authenticated with it at least once. + */ + lastUsedAt: string | null; /** Format: date-time */ - to: string; + createdAt: string; + }; + /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ + AppPasswordReveal: { + /** Format: uuid */ + id: string; + /** + * Format: uri + * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the link stops working. Five minutes after creation; the password itself does not expire. + */ + revealExpiresAt: string; + }; + /** @description Body for POST /api/mailboxes/:id/app-passwords. */ + CreateAppPassword: { + name: string; + /** + * @default [ + * "imap", + * "smtp" + * ] + */ + scopes: ("imap" | "smtp")[]; + }; + /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ + VerifyEmail: { + /** Format: email */ + email: string; + }; + TopicListV1: { + data: components["schemas"]["TopicV1"][]; + cursor: string | null; + has_more: boolean; + }; + /** @description One subject this project mails about. */ + TopicV1: { + id: string; + /** @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. */ + key: string; + name: string; + description: string | null; + /** @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`. */ + default_opt_in: boolean; + /** @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. */ + archived: boolean; + /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ + subscribed_count: number; + unsubscribed_count: number; + /** Format: date-time */ + created_at: string; + }; + TopicCreateV1: { + /** @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. */ + key: string; + name: string; + description?: string | null; + default_opt_in?: boolean; + }; + /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ + TopicUpdateV1: { + name?: string; + description?: string | null; + default_opt_in?: boolean; + archived?: boolean; + }; + TopicSubscriptionV1: { + topic_id: string; + contact_id: string; + status: components["schemas"]["TopicSubscriptionStatusV1"]; + /** Format: date-time */ + confirmed_at: string | null; + /** @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. */ + confirmation_url: string | null; + }; + /** + * @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 {string} + */ + TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; + TopicSubscribeV1: { + /** Format: uuid */ + contact_id: string; + /** @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. */ + subscribed: boolean; + }; + /** @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. */ + ContactTopicPreferencesV1: { + contact_id: string; + /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ + subscribed: boolean; + topics: { + topic_id: string; + /** @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. */ + key: string; + name: string; + /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ + subscribed: boolean; + pending: boolean; + }[]; + }; + /** @description One verdict per address, in the order they were given. */ + EmailValidationBatchV1: { + results: components["schemas"]["EmailValidationV1"][]; + }; + /** @description One address's verdict, with the evidence behind it. */ + EmailValidationV1: { + email: string; + verdict: components["schemas"]["EmailValidationVerdictV1"]; + /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ + is_disposable: boolean; + /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ + is_role_address: boolean; + /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ + is_personal: boolean; + /** @description The domain publishes MX records. */ + has_mx_records: boolean; + /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ + reasons: string[]; + }; + /** + * @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 {string} + */ + EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; + EmailValidationBatchRequestV1: { + /** @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. */ + emails: string[]; + }; + /** @description One bulk validation run over a list. */ + EmailValidationRunV1: { + id: string; + list_id: string | null; + /** @enum {string} */ + status: "pending" | "running" | "completed" | "failed"; + /** @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. */ + processed_count: number; + deliverable_count: number; + undeliverable_count: number; + risky_count: number; + /** Format: date-time */ + started_at: string | null; + /** Format: date-time */ + completed_at: string | null; + /** @description Set only on `failed`. Prose for an operator; never parse it. */ + failure_reason: string | null; + /** Format: date-time */ + created_at: string; + }; + /** @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. */ + EmailValidationResultListV1: { + data: (components["schemas"]["EmailValidationV1"] & { + contact_id: string | null; + })[]; + /** @description Pass as `cursor` for the next page; null on the last. */ + cursor: string | null; + has_more: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +interface operations { + v1ListCampaigns: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateCampaign: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Create"]; + }; + }; + responses: { + /** @description Campaign created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1Deleted"]; + }; + }; + /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Update"]; + }; + }; + responses: { + /** @description The updated campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendCampaign: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CampaignV1Send"]; + }; + }; + responses: { + /** @description The campaign, now `SENDING` or `SCHEDULED` */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CancelCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The cancelled campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1PauseCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The paused campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only a `SENDING` campaign can be paused. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ResumeCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The resumed campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetCampaignStats: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1Stats"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListCampaignFailures: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Failed sends */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1FailureList"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1RetryCampaignFailures: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The retry was queued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1RetryFailed"]; + }; + }; + /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a retry is already running for this campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListSegments: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateSegment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Create"]; + }; + }; + responses: { + /** @description Segment created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The segment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1Deleted"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the segment is still used by one or more active campaigns. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Update"]; + }; + }; + responses: { + /** @description The updated segment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListSegmentContacts: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment member list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentContactV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListWorkflows: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateWorkflow: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowCreateV1"]; + }; + }; + responses: { + /** @description Workflow created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowDeletedV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the workflow still has running executions. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowUpdateV1"]; + }; + }; + responses: { + /** @description Updated workflow */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the trigger cannot be changed while executions are running. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListWorkflowExecutions: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only executions in this state. */ + status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + }; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Execution list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1StartWorkflowExecution: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowExecutionStartV1"]; + }; + }; + responses: { + /** @description Execution started */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CancelWorkflowExecution: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow execution id. */ + execution_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancelled execution */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no execution with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflowStats: { + parameters: { + query?: { + /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ + from?: string | null; + }; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStatsV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflowGraph: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow's graph */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowGraphV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ReplaceWorkflowGraph: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; + }; + }; + responses: { + /** @description The graph as it now stands */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowGraphV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CloneWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["WorkflowCloneV1"]; + }; + }; + responses: { + /** @description The cloned workflow */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1PauseWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow, and the number of runs this call cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStateChangeV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ResumeWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow, with `cancelled_executions` always 0 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStateChangeV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmailV1"]; + }; + }; + responses: { + /** @description Email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendTestEmail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendTestEmailV1"]; + }; + }; + responses: { + /** @description Test email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailTestV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + listEmails: { + parameters: { + query?: { + limit?: number; + cursor?: string; + tag?: string; + /** @description Delivery lifecycle of the message. Engagement is reported separately. */ + status?: components["schemas"]["EmailDeliveryStatus"]; + from?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Email list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailListResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + sendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmail"]; + }; + }; + responses: { + /** @description Email accepted / sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendEmailResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @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. */ - ApiKey: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** Format: uuid */ - domainId: string | null; - /** Format: uuid */ - id: string; - /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ - lastFour: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - lastUsedAt: string | null; - name: string; - /** @enum {string} */ - permission: "FULL" | "SENDING_ONLY"; - /** Format: uuid */ - projectId: string; - /** - * Format: date-time - * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. - */ - revokedAt: string | null; - /** @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. */ - scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test")[]; + }; + getEmail: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ - ApiKeyListResponse: { - data: components["schemas"]["ApiKey"][]; - /** @enum {boolean} */ - success: true; + requestBody?: never; + responses: { + /** @description Email and its delivery history */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailDetailResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ - AppPassword: { - /** Format: date-time */ - createdAt: string; - /** Format: uuid */ - id: string; - /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ - lastFour: string; - /** - * Format: date-time - * @description Null until a mail client has authenticated with it at least once. - */ - lastUsedAt: string | null; - /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ - name: string; - /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ - scopes: ("imap" | "smtp")[]; + }; + sendEmailBatch: { + parameters: { + query?: never; + header?: { + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; }; - /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ - AppPasswordReveal: { - /** Format: uuid */ - id: string; - /** - * Format: date-time - * @description When the link stops working. Five minutes after creation; the password itself does not expire. - */ - revealExpiresAt: string; - /** - * Format: uri - * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. - */ - revealUrl: string; + requestBody: { + content: { + "application/json": components["schemas"]["BatchSendBody"]; + }; }; - /** @description Per-row result in a batch send response. */ - BatchEntryResult: { - data?: components["schemas"]["SendEmailData"]; - error?: { - code: string; - message: string; + responses: { + /** @description All entries sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Partial success — at least one entry failed */ + 207: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - index: number; - /** @enum {string} */ - status: "ok" | "error"; - }; - /** @description Batch send wrapper. Up to 100 entries. */ - BatchSendBody: { - emails: components["schemas"]["SendEmail"][]; - }; - /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ - BatchSendResponse: { - data: components["schemas"]["BatchEntryResult"][]; - success: boolean; - }; - /** @description A campaign as exposed on the v1 API. */ - CampaignV1: { - /** @enum {string} */ - audience_type: "ALL" | "FILTERED" | "SEGMENT"; - /** Format: date-time */ - created_at: string; - /** Format: uuid */ - id: string; - name: string; - /** Format: date-time */ - scheduled_at: string | null; - /** Format: date-time */ - sent_at: string | null; - stats: { - bounced: number; - clicked: number; - delivered: number; - opened: number; - sent: number; - total_recipients: number; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {string} */ - status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; - subject: string; - }; - /** @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`. */ - CampaignV1Create: { - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** - * @description `ALL` — every subscribed contact. `FILTERED` — the contacts matching `audience_condition`. `SEGMENT` — the members of `segment_id`. - * @enum {string} - */ - audience_type: "ALL" | "FILTERED" | "SEGMENT"; - body: string; - description?: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from: string; - from_name?: string | null; - name: string; - /** Format: email */ - reply_to?: string | null; - /** Format: uuid */ - segment_id?: string; - subject: string; - /** - * @default MARKETING - * @enum {string} - */ - type: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; - }; - /** @description Acknowledgement that a campaign was deleted. */ - CampaignV1Deleted: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; - }; - /** @description Cursor-paginated list of campaigns. */ - CampaignV1List: { - data: components["schemas"]["CampaignV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; }; - /** @description Body for POST /api/v1/campaigns/{id}/send. */ - CampaignV1Send: { - /** - * Format: date-time - * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. - */ - scheduled_for?: string; + }; + cancelScheduledEmail: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Materialized delivery and engagement counters for one campaign. */ - CampaignV1Stats: { - bounce_rate: number; - bounced: number; - click_rate: number; - clicked: number; - delivered: number; - delivery_rate: number; - open_rate: number; - opened: number; - sent: number; - total_recipients: number; + requestBody?: never; + responses: { + /** @description Email cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Email already past PENDING */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ - CampaignV1Update: { - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** @enum {string} */ - audience_type?: "ALL" | "FILTERED" | "SEGMENT"; - body?: string; - description?: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from?: string; - from_name?: string | null; - name?: string; - /** Format: email */ - reply_to?: string | null; - /** Format: uuid */ - segment_id?: string; - subject?: string; - /** @enum {string} */ - type?: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + }; + listContacts: { + parameters: { + query?: { + limit?: number; + cursor?: string; + search?: string; + subscribed?: "true" | "false"; + }; + header?: never; + path?: never; + cookie?: never; }; - /** @description A subscriber/contact within a project. */ - Contact: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - customFields?: { - [key: string]: unknown; - } | null; - /** Format: email */ - email: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - subscribed: boolean; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + requestBody?: never; + responses: { + /** @description Contact list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactListResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Bulk create up to 1000 contacts. */ - ContactBulkCreateBody: { - contacts: components["schemas"]["CreateContact"][]; + }; + createContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ - ContactBulkDeleteBody: { - emails?: string[]; - ids?: string[]; + requestBody: { + content: { + "application/json": components["schemas"]["CreateContact"]; + }; }; - /** @description Cursor-paginated list of contacts. */ - ContactListResponse: { - data: { - data: components["schemas"]["Contact"][]; - hasMore: boolean; - /** @description Cursor for the next page, or null on the last page. */ - nextCursor: string | null; - total: number; + responses: { + /** @description Contact created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Email already exists for this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; }; - CreateApiKeyBody: { - /** Format: uuid */ - domainId?: string | null; - name: string; - /** @enum {string} */ - permission?: "FULL" | "SENDING_ONLY"; - /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `permission`. A `SENDING_ONLY` key may carry only `emails:send`. */ - scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test")[]; + }; + upsertContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/mailboxes/:id/app-passwords. */ - CreateAppPassword: { - name: string; - /** - * @default [ - * "imap", - * "smtp" - * ] - */ - scopes: ("imap" | "smtp")[]; + requestBody: { + content: { + "application/json": components["schemas"]["CreateContact"]; + }; }; - /** @description Body for POST /api/contacts and /api/contacts/upsert. */ - CreateContact: { - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - customFields?: { - [key: string]: unknown; + responses: { + /** @description Contact created or updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; }; - CreateMailboxBody: { - displayName?: string; - /** - * Format: uuid - * @description A VERIFIED domain belonging to this project. - */ - domainId: string; - /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ - localPart: string; - /** - * Format: uuid - * @description Defaults to the project the credential resolves to. Naming a different one is refused. - */ - projectId?: string; - /** @description NOT IMPLEMENTED — sending any value answers 400. */ - quotaBytes?: number; + }; + bulkCreateContacts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/templates. */ - CreateTemplate: { - body: string; - description?: string; - /** Format: email */ - from: string; - fromName?: string | null; - name: string; - /** Format: email */ - replyTo?: string | null; - subject: string; - /** - * @default MARKETING - * @enum {string} - */ - type: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkCreateBody"]; + }; }; - /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ - CreateWebhook: { - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** Format: uri */ - url: string; + responses: { + /** @description Bulk-create result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + created: number; + skipped: number; + errors: { + index: number; + message: string; + }[]; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description A sending domain registered with SES. */ - Domain: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - dkim?: { - name: string; - type: string; - value: string; - }[]; - /** Format: uuid */ - id: string; - /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - name: string; - /** Format: uuid */ - projectId: string; - region?: string | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - verified: boolean; + }; + bulkDeleteContacts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description List of all domains for the auth'd project. */ - DomainListResponse: { - data: components["schemas"]["Domain"][]; - /** @enum {boolean} */ - success: true; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkDeleteBody"]; + }; }; - /** @description Outcome of a verification check against SES. */ - DomainVerificationStatus: { - dkim?: { - name: string; - type: string; - value: string; - }[]; - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - mxRecords?: string[]; - verified: boolean; + responses: { + /** @description Bulk-delete result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + deleted: number; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description A sent (or queued) transactional email. */ - Email: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - error?: string | null; - from: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - status: "PENDING" | "SENT" | "DELIVERED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED"; - subject: string; - tags: string[]; - to: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + }; + getContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Single email with its events. */ - EmailGetResponse: { - data: components["schemas"]["Email"]; - /** @enum {boolean} */ - success: true; + requestBody?: never; + responses: { + /** @description Contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Cursor-paginated list of emails. */ - EmailListResponse: { - data: components["schemas"]["Email"][]; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; + }; + deleteContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Receipt for a sandbox test send. */ - EmailTestV1: { - /** - * Format: email - * @description This project's sandbox sender — resolved server-side, never from the body. - */ - from: string; - /** - * Format: uuid - * @description The Email row this send created. - */ - id: string; - /** - * @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 {boolean} - */ - sandbox: true; - /** - * @description Delivery status at the moment of the response — `PENDING` for a send still queued. - * @enum {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; + requestBody?: never; + responses: { + /** @description Contact deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IdResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Receipt for a single transactional send. */ - EmailV1: { - /** - * Format: email - * @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. - */ - from: string; - /** - * Format: uuid - * @description The Email row this send created. Quote it in support requests. - */ - id: string; - /** - * @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 {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; + }; + updateContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ - Error: { - error: { - code: string; - details?: { - errors: unknown[]; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateContactBody"]; + }; + }; + responses: { + /** @description Updated contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; }; - message: string; }; - /** @enum {boolean} */ - success?: false; - }; - /** @description Every distinct event name in the project, most frequent first. */ - EventNamesV1: { - data: string[]; - }; - /** @description Per-name event counts over the applied window. */ - EventStatsV1: { - data: { - count: number; - name: string; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; - }; - /** @description Body for POST /api/v1/events. */ - EventTrackV1: { - /** - * Format: uuid - * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. - */ - contact_id?: string; - /** @description Arbitrary event payload. */ - data?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @description Event name, e.g. `user.signup`. */ - name: string; }; - /** @description A recorded custom event. */ - EventV1: { - /** Format: uuid */ - contact_id: string | null; - /** Format: date-time */ - created_at: string; - /** @description The payload recorded with the event, or null. */ - data: { - [key: string]: unknown; - } | null; - /** Format: uuid */ - email_id: string | null; - /** Format: uuid */ - id: string; - name: string; + }; + v1ListContacts: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the email address. */ + search?: string; + /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ + subscribed?: "true" | "false"; + }; + header?: never; + path?: never; + cookie?: never; }; - /** @description Cursor-paginated list of events, newest first. */ - EventV1List: { - data: components["schemas"]["EventV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + requestBody?: never; + responses: { + /** @description Contact list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; - /** @description A filter condition: one or more groups combined with `logic`. */ - FilterConditionV1: { - groups: components["schemas"]["FilterGroupV1"][]; - /** @enum {string} */ - logic: "AND" | "OR"; + }; + v1CreateContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @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. */ - FilterGroupV1: { - conditions?: components["schemas"]["FilterConditionV1"]; - filters: components["schemas"]["SegmentFilterV1"][]; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Create"]; + }; }; - /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ - IdResponse: { - data: { - /** Format: uuid */ - id: string; + responses: { + /** @description The created contact */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a contact with this email already exists in this project. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/lists/{id}/subscribe. */ - ListSubscribe: { - /** - * @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. - * @default false - */ - allowResubscribe: boolean; - /** @description Custom fields to upsert onto the contact as part of subscribing. */ - data?: { - [key: string]: unknown; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** Format: email */ - email: string; }; - /** @description Result of a list-subscribe call. */ - ListSubscribeResponse: { - data: { - /** @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. */ - confirmToken?: string; - /** @description True when the membership row did not exist before this call. */ - created: boolean; - /** Format: uuid */ - membershipId: string; - /** - * @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 {string|null} - */ - previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; - /** @enum {string} */ - status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; + }; + v1GetContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/lists/{id}/unsubscribe. */ - ListUnsubscribe: { - /** Format: email */ - email: string; + cookie?: never; }; - /** @description Echoes the address that was unsubscribed. */ - ListUnsubscribeResponse: { - data: { - /** Format: email */ - email: string; + requestBody?: never; + responses: { + /** @description The contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description A receiving mailbox on one of the project's verified domains. */ - Mailbox: { - /** - * Format: email - * @description The full mailbox address, e.g. `support@superbooks.io`. - */ - address: string; - /** Format: date-time */ - createdAt: string; - displayName: string | null; - /** - * Format: uuid - * @description The verified domain this mailbox lives on. - */ - domainId: string; - /** Format: uuid */ - id: string; - /** @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. */ - quotaBytes: number | null; - /** - * @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 {string} - */ - status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; - }; - /** @description A mailbox plus its IMAP/SMTP connection settings. */ - MailboxDetail: components["schemas"]["Mailbox"] & { - /** @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. */ - settings: { - imap: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; }; - smtp: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ - Problem: { - /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ - code: string; - /** @description Explanation specific to this occurrence. */ - detail?: string; - /** @description Field-level failures. Present on 422 `validation_error` responses. */ - errors?: { - code: string; - message: string; - /** @description RFC 6901 JSON Pointer to the offending field. */ - pointer: string; - }[]; - /** @description Request path the failure occurred on. */ - instance?: string; - /** @description Correlation id — quote it in support requests. */ - request_id?: string; - /** @description HTTP status code, repeated in the body. */ - status: number; - /** @description Short, stable summary — the same for every occurrence of a `type`. */ - title: string; - /** - * Format: uri - * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. - */ - type: string; - }; - ProjectRecord: { - billingLimitCampaigns: number | null; - billingLimitInbound: number | null; - billingLimitTransactional: number | null; - billingLimitWorkflows: number | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - disabled: boolean; - disabledReason: string | null; - /** Format: uuid */ - id: string; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - name: string; - organizationId: string | null; - /** @description Local-part of the sandbox quick-start sender; null until first derived. */ - sandboxHandle: string | null; - sesRegion: string | null; - stripeCustomerId: string | null; - stripeSubscriptionId: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description The project the presented credential is scoped to. */ - ProjectV1: { - /** Format: date-time */ - created_at: string; - /** @description A disabled project sends nothing; every send is refused. */ - disabled: boolean; - /** Format: uuid */ - id: string; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - name: string; - /** @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. */ - sandbox_address: string | null; - /** @description Locked once the first domain is added. */ - ses_region: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - }; - /** @description A contact belonging to a segment. */ - SegmentContactV1: { - /** Format: date-time */ - created_at: string; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: { - [key: string]: unknown; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - email: string; - /** Format: uuid */ - id: string; - subscribed: boolean; - }; - /** @description Cursor-paginated list of the contacts belonging to a segment. */ - SegmentContactV1List: { - data: components["schemas"]["SegmentContactV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @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). */ - SegmentFilterV1: { - field: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; - /** @enum {string} */ - unit?: "days" | "hours" | "minutes"; - value?: unknown; - }; - /** @description A segment as exposed on the v1 API. */ - SegmentV1: { - condition: components["schemas"]["FilterConditionV1"] | null; - /** Format: date-time */ - created_at: string; - description: string | null; - /** Format: uuid */ - id: string; - member_count: number; - name: string; - track_membership: boolean; - /** @enum {string} */ - type: "DYNAMIC" | "STATIC"; - /** Format: date-time */ - updated_at: string; }; - /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ - SegmentV1Create: { - condition?: components["schemas"]["FilterConditionV1"]; - description?: string; - name: string; - /** - * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. - * @default false - */ - track_membership: boolean; - /** - * @default DYNAMIC - * @enum {string} - */ - type: "DYNAMIC" | "STATIC"; + }; + v1DeleteContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description Acknowledgement that a segment was deleted. */ - SegmentV1Deleted: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; + requestBody?: never; + responses: { + /** @description Contact deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1Deleted"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; - /** @description Cursor-paginated list of segments. */ - SegmentV1List: { - data: components["schemas"]["SegmentV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + }; + v1UpdateContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ - SegmentV1Update: { - condition?: components["schemas"]["FilterConditionV1"]; - description?: string; - name?: string; - track_membership?: boolean; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Update"]; + }; }; - /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ - SendEmail: { - attachments?: { - content: string; - contentId?: string; - contentType: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - filename: string; - }[]; - bcc?: string[]; - body?: string; - cc?: string[]; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + responses: { + /** @description The updated contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; }; - from?: string | { - /** Format: email */ - email: string; - name?: string; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - headers?: { - [key: string]: string; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - name?: string; - /** Format: email */ - reply?: string; - subject?: string; - subscribed?: boolean; - tags?: string[]; - /** Format: uuid */ - template?: string; - to: string | { - /** Format: email */ - email: string; - name?: string; - } | (string | { - /** Format: email */ - email: string; - name?: string; - })[]; - }; - /** @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`. */ - SendEmailData: { - emails: components["schemas"]["SendEmailRecipientResult"][]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; }; - /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ - SendEmailRecipientResult: { - contact: { - /** Format: email */ - email: string; - /** Format: uuid */ + }; + subscribeToList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description List id. */ id: string; }; - /** Format: uuid */ - email: string; + cookie?: never; }; - /** @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. */ - SendEmailResponse: { - data: components["schemas"]["SendEmailData"]; - /** @enum {boolean} */ - success: true; + requestBody: { + content: { + "application/json": components["schemas"]["ListSubscribe"]; + }; }; - /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ - SendEmailV1: { - attachments?: { - content: string; - contentId?: string; - contentType: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - filename: string; - }[]; - bcc?: string[]; - body?: string; - cc?: string[]; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + responses: { + /** @description Contact subscribed, or an existing membership returned unchanged */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSubscribeResponse"]; + }; }; - from?: string | { - /** Format: email */ - email: string; - name?: string; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - headers?: { - [key: string]: string; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - name?: string; - /** Format: email */ - reply?: string; - subject?: string; - subscribed?: boolean; - tags?: string[]; - /** Format: uuid */ - template?: string; - /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ - to: string | { - /** Format: email */ - email: string; - name?: string; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - }; - /** @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. */ - SendTestEmailV1: { - /** @description HTML body. Merge tags are rendered as on any other send. */ - body: string; - /** @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. */ - from?: string; - subject: string; - /** - * Format: email - * @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. - */ - to?: string; - }; - /** @description Bare success envelope with no payload. */ - SuccessEmpty: { - /** @enum {boolean} */ - success: true; - }; - /** @description A single suppressed-email record. */ - Suppression: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** Format: email */ - email: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD"; - }; - /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ - SuppressionCheckResponse: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt?: string; - /** @enum {string} */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; - suppressed: boolean; - }; - /** @description Cursor-paginated list of suppressions. */ - SuppressionListResponse: { - cursor?: string | null; - data: components["schemas"]["Suppression"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; - }; - /** @description A reusable email template. */ - Template: { - body: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - description?: string | null; - /** Format: email */ - from: string; - fromName?: string | null; - /** Format: uuid */ - id: string; - name: string; - /** Format: uuid */ - projectId: string; - /** Format: email */ - replyTo?: string | null; - subject: string; - /** @enum {string} */ - type: "MARKETING" | "TRANSACTIONAL" | "HEADLESS"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description Cursor-paginated list of templates. */ - TemplateListResponse: { - data: { - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - data: components["schemas"]["Template"][]; - hasMore: boolean; - total: number; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/track — record a custom event for a contact. */ - TrackEvent: { - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + /** @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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** Format: email */ - email: string; - event: string; - subscribed?: boolean; - }; - /** @description Response from POST /api/track. */ - TrackEventResponse: { - data: { - /** Format: uuid */ - contact: string; - /** Format: uuid */ - event: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; }; - /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ - UpdateContactBody: { - customFields?: { - [key: string]: unknown; + }; + unsubscribeFromList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description List id. */ + id: string; }; - subscribed?: boolean; - }; - /** @description Body for PATCH /api/templates/{id}. */ - UpdateTemplate: { - body?: string; - description?: string; - /** Format: email */ - from?: string; - fromName?: string | null; - name?: string; - /** Format: email */ - replyTo?: string | null; - subject?: string; - /** @enum {string} */ - type?: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + cookie?: never; }; - /** @description Body for PATCH /api/webhooks/{id}. */ - UpdateWebhook: { - eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; - /** Format: uri */ - url?: string; + requestBody: { + content: { + "application/json": components["schemas"]["ListUnsubscribe"]; + }; }; - /** @description Current email usage against the limits that are actually enforced. */ - UsageV1: { - daily: { - /** @description Today's sends. Null when the counter could not be read. */ - emails_sent: number | null; - limit: number; - /** @enum {string} */ - trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + responses: { + /** @description Contact unsubscribed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListUnsubscribeResponse"]; + }; }; - monthly: { - categories: { - campaign: { - emails_sent: number; - limit: number | null; - }; - inbound: { - emails_sent: number; - limit: number | null; - }; - transactional: { - emails_sent: number; - limit: number | null; - }; - workflow: { - emails_sent: number; - limit: number | null; - }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; - emails_sent: number; - /** @description Monthly cap on the total. Null when per-category limits govern instead. */ - limit: number | null; }; - /** - * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. - * @enum {string} - */ - plan: "free" | "pro" | "custom"; - }; - /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ - VerifyEmail: { - /** Format: email */ - email: string; - }; - /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ - VerifyEmailResponse: { - data: { - /** Format: email */ - email: string; - reason?: string; - valid: boolean; - } & { - [key: string]: unknown; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; }; - /** @description A user-managed outbound webhook. */ - Webhook: { - consecutiveFailures: number; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - disabledAt?: string | null; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** Format: uuid */ - id: string; - lastFour?: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - /** Format: uri */ - url: string; + }; + v1ListLists: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; }; - /** @description An attempted webhook delivery. */ - WebhookCall: { - attempt: number; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - eventType: string; - /** Format: uuid */ - id: string; - payload: { - [key: string]: unknown; + requestBody?: never; + responses: { + /** @description Subscriber lists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - responseBody?: string | null; - responseStatus?: number | null; - /** @enum {string} */ - status: "PENDING" | "SUCCESS" | "FAILED"; - /** Format: uuid */ - webhookId: string; - }; - /** @description Cursor-paginated list of recent calls for a single webhook. */ - WebhookCallsListResponse: { - cursor?: string | null; - data: components["schemas"]["WebhookCall"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; - }; - /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ - WebhookCreateResponse: { - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { - /** @description Plaintext shared secret. Returned ONCE on create. */ - secret: string; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Single webhook (no secret). */ - WebhookGetResponse: { - data: components["schemas"]["Webhook"]; - /** @enum {boolean} */ - success: true; }; - /** @description List of webhooks for the auth'd project. */ - WebhookListResponse: { - data: components["schemas"]["Webhook"][]; - /** @enum {boolean} */ - success: true; + }; + v1CreateList: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ - WebhookRotateSecretResponse: { - data: { - /** Format: uuid */ - id: string; - /** @description New plaintext shared secret. */ - secret: string; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Create"]; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/v1/workflows. */ - WorkflowCreateV1: { - allow_reentry?: boolean; - description?: string; - /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ - enabled?: boolean; - /** @description The custom event that starts this workflow, e.g. `user.signup`. */ - event_name: string; - name: string; - }; - /** @description Confirmation that a workflow was deleted. */ - WorkflowDeletedV1: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; }; - /** @description Body for POST /api/v1/workflows/{id}/executions. */ - WorkflowExecutionStartV1: { - /** - * Format: uuid - * @description Contact to enter the workflow. Must belong to this project. - */ - contact_id: string; - /** @description Extra variables merged into the contact's data for this run. */ - context?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + responses: { + /** @description The created list */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1"]; + }; }; - }; - /** @description One contact's run through a workflow. */ - WorkflowExecutionV1: { - /** Format: date-time */ - completed_at: string | null; - /** Format: uuid */ - contact_id: string; - /** Format: uuid */ - current_step_id: string | null; - exit_reason: string | null; - /** Format: uuid */ - id: string; - /** Format: date-time */ - started_at: string; - /** @enum {string} */ - status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - /** Format: uuid */ - workflow_id: string; - }; - /** @description Cursor-paginated list of workflow executions, newest first. */ - WorkflowExecutionV1List: { - data: components["schemas"]["WorkflowExecutionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description Execution, email and conversion totals for one workflow. */ - WorkflowStatsV1: { - avg_duration_ms: number | null; - /** @description Execution counts keyed by status; a status with no executions is absent. */ - by_status: { - [key: string]: number; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ - completion_rate: number | null; - conversions: { - count: number; - event_name: string; - /** Format: uuid */ - goal_id: string; - name: string; - }[]; - emails: { - clicked: number; - opened: number; - sent: number; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - total: number; - /** Format: uuid */ - workflow_id: string; - }; - /** @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. */ - WorkflowUpdateV1: { - allow_reentry?: boolean; - description?: string; - enabled?: boolean; - event_name?: string; - /** @description Per-workflow start rate cap. `null` removes the cap. */ - max_executions_per_hour?: number | null; - name?: string; }; - /** @description An automation workflow as exposed on the v1 API. */ - WorkflowV1: { - allow_reentry: boolean; - /** Format: date-time */ - created_at: string; - description: string | null; - enabled: boolean; - /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ - event_name: string | null; - /** Format: uuid */ - id: string; - max_executions_per_hour: number | null; - name: string; - /** @enum {string} */ - trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; - /** Format: date-time */ - updated_at: string; - /** @description Incremented on every structural (step/transition) change. */ - version: number; + }; + v1GetList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description Cursor-paginated list of workflows. */ - WorkflowV1List: { - data: components["schemas"]["WorkflowV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + requestBody?: never; + responses: { + /** @description The list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -interface operations { - listContacts: { + v1DeleteList: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - subscribed?: "true" | "false"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact list */ + /** @description List deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactListResponse"]; + "application/json": components["schemas"]["ListV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createContact: { + v1UpdateList: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateContact"]; + "application/json": components["schemas"]["ListV1Update"]; }; }; responses: { - /** @description Contact created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: components["schemas"]["Contact"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description The updated list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Email already exists for this project */ - 409: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - bulkCreateContacts: { + listDomains: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactBulkCreateBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Bulk-create result */ + /** @description Domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - created: number; - errors: { - index: number; - message: string; - }[]; - skipped: number; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainListResponse"]; }; }; /** @description Validation error */ @@ -3175,15 +10147,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3204,7 +10167,7 @@ interface operations { }; }; }; - bulkDeleteContacts: { + addDomain: { parameters: { query?: never; header?: never; @@ -3213,22 +10176,20 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContactBulkDeleteBody"]; + "application/json": components["schemas"]["AddDomainBody"]; }; }; responses: { - /** @description Bulk-delete result */ - 200: { + /** @description Domain added */ + 201: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: { - deleted: number; - }; /** @enum {boolean} */ success: true; + data: components["schemas"]["Domain"]; }; }; }; @@ -3259,8 +10220,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; @@ -3268,8 +10229,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; @@ -3277,8 +10238,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Internal server error */ - 500: { + /** @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. */ + 502: { headers: { [name: string]: unknown; }; @@ -3288,29 +10249,27 @@ interface operations { }; }; }; - upsertContact: { + getDomain: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateContact"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Contact created or updated */ + /** @description Domain */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Contact"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Domain"]; }; }; }; @@ -3341,8 +10300,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -3370,7 +10329,7 @@ interface operations { }; }; }; - getContact: { + deleteDomain: { parameters: { query?: never; header?: never; @@ -3381,17 +10340,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Contact */ + /** @description Domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Contact"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; /** @description Validation error */ @@ -3450,7 +10405,7 @@ interface operations { }; }; }; - deleteContact: { + assignDomainStream: { parameters: { query?: never; header?: never; @@ -3459,15 +10414,23 @@ interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AssignDomainStream"]; + }; + }; responses: { - /** @description Contact deleted */ + /** @description Updated sending identity */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Domain"]; + }; }; }; /** @description Validation error */ @@ -3526,7 +10489,7 @@ interface operations { }; }; }; - updateContact: { + getDomainVerification: { parameters: { query?: never; header?: never; @@ -3535,22 +10498,18 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateContactBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated contact */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Contact"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["DomainVerificationStatus"]; }; }; }; @@ -3590,15 +10549,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3619,22 +10569,28 @@ interface operations { }; }; }; - listDomains: { + verifyDomain: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain list */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainListResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["DomainVerificationStatus"]; + }; }; }; /** @description Validation error */ @@ -3664,6 +10620,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3684,29 +10649,36 @@ interface operations { }; }; }; - addDomain: { + startDomainSetup: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddDomainBody"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Domain added */ - 201: { + /** @description Guided setup session */ + 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Domain"]; /** @enum {boolean} */ success: true; + data: { + token: string; + /** + * Format: uri + * @description Open this in a browser to publish the records. Short-lived and domain-specific. + */ + connectUrl: string; + /** @description When `connectUrl` stops working. */ + expiresAt: string; + }; }; }; }; @@ -3737,8 +10709,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -3746,8 +10718,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Internal server error */ - 500: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; @@ -3755,8 +10727,8 @@ interface operations { "application/json": 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. */ - 502: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; @@ -3766,419 +10738,409 @@ interface operations { }; }; }; - getDomain: { + v1ListDomains: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain */ + /** @description Sending domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Domain"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DomainV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteDomain: { + v1CreateDomain: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DomainV1Create"]; + }; + }; responses: { - /** @description Domain removed */ - 200: { + /** @description The registered sending domain, awaiting DNS */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `conflict` — this domain is already registered to a project you can send from. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - startDomainSetup: { + v1GetDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Guided setup session */ + /** @description The sending domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - /** - * Format: uri - * @description Open this in a browser to publish the records. Short-lived and domain-specific. - */ - connectUrl: string; - /** @description When `connectUrl` stops working. */ - expiresAt: string; - token: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomainVerification: { + v1DeleteDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description Sending domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["DomainVerificationStatus"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - verifyDomain: { + v1VerifyDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The sending domain, as SES and DNS now report it */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["DomainVerificationStatus"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listEmails: { + listTemplates: { parameters: { query?: { limit?: number; cursor?: string; - tag?: string; - status?: "PENDING" | "SENT" | "DELIVERED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED"; - from?: string; + search?: string; + emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; }; header?: never; path?: never; @@ -4186,13 +11148,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Email list */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailListResponse"]; + "application/json": components["schemas"]["TemplateListResponse"]; }; }; /** @description Validation error */ @@ -4222,6 +11184,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -4242,32 +11213,33 @@ interface operations { }; }; }; - sendEmail: { + createTemplate: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SendEmail"]; + "application/json": components["schemas"]["CreateTemplate"]; }; }; responses: { - /** @description Email accepted / sent */ - 200: { + /** @description Template created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SendEmailResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -4285,7 +11257,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -4294,16 +11266,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; @@ -4330,51 +11293,33 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - sendEmailBatch: { + getTemplate: { parameters: { query?: never; - header?: { - "Idempotency-Key"?: string; + header?: never; + path: { + id: string; }; - path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["BatchSendBody"]; - }; - }; + requestBody?: never; responses: { - /** @description All entries sent */ + /** @description Template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BatchSendResponse"]; - }; - }; - /** @description Partial success — at least one entry failed */ - 207: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BatchSendResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -4392,7 +11337,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -4401,17 +11346,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -4437,18 +11373,9 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - getEmail: { + deleteTemplate: { parameters: { query?: never; header?: never; @@ -4459,13 +11386,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Email */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailGetResponse"]; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -4504,6 +11431,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Template still in use */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -4524,7 +11460,7 @@ interface operations { }; }; }; - cancelScheduledEmail: { + updateTemplate: { parameters: { query?: never; header?: never; @@ -4533,15 +11469,23 @@ interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTemplate"]; + }; + }; responses: { - /** @description Email cancelled */ + /** @description Updated template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailGetResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; /** @description Validation error */ @@ -4580,8 +11524,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already past PENDING */ - 409: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -4609,386 +11553,411 @@ interface operations { }; }; }; - subscribeToList: { + v1ListTemplates: { parameters: { - query?: never; - header?: never; - path: { - /** @description List id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the name. */ + search?: string; + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListSubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact subscribed, or an existing membership returned unchanged */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSubscribeResponse"]; + "application/json": components["schemas"]["TemplateV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateV1Create"]; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + }; + responses: { + /** @description The created template */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ - 409: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - unsubscribeFromList: { + v1GetTemplate: { parameters: { query?: never; header?: never; path: { - /** @description List id. */ + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListUnsubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact unsubscribed */ + /** @description The template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUnsubscribeResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listMailboxes: { + v1DeleteTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox list */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Mailbox"][]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["TemplateV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createMailbox: { + v1UpdateTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateMailboxBody"]; + "application/json": components["schemas"]["TemplateV1Update"]; }; }; responses: { - /** @description Mailbox provisioned */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: components["schemas"]["Mailbox"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description The updated template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getMailbox: { + listSnippets: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + cursor?: string; + search?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox with connection settings */ + /** @description Snippet list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["MailboxDetail"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["SnippetListResponse"]; }; }; /** @description Validation error */ @@ -5018,8 +11987,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -5047,30 +12016,29 @@ interface operations { }; }; }; - deleteMailbox: { + createSnippet: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSnippet"]; + }; + }; responses: { - /** @description Mailbox deleted */ - 200: { + /** @description Snippet created */ + 201: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: { - /** @enum {boolean} */ - deleted: true; - }; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5101,8 +12069,17 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -5130,7 +12107,7 @@ interface operations { }; }; }; - listAppPasswords: { + getSnippet: { parameters: { query?: never; header?: never; @@ -5141,16 +12118,16 @@ interface operations { }; requestBody?: never; responses: { - /** @description App password list */ + /** @description Snippet */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["AppPassword"][]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5210,7 +12187,7 @@ interface operations { }; }; }; - createAppPassword: { + deleteSnippet: { parameters: { query?: never; header?: never; @@ -5219,23 +12196,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateAppPassword"]; - }; - }; - responses: { - /** @description App password created; the secret is behind the one-time link */ - 201: { + requestBody?: never; + responses: { + /** @description Snippet deleted */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["AppPasswordReveal"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -5294,31 +12263,31 @@ interface operations { }; }; }; - revokeAppPassword: { + updateSnippet: { parameters: { query?: never; header?: never; path: { id: string; - passwordId: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSnippet"]; + }; + }; responses: { - /** @description App password revoked */ + /** @description Updated snippet */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: { - /** @enum {boolean} */ - revoked: true; - }; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5358,6 +12327,24 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5378,25 +12365,22 @@ interface operations { }; }; }; - listApiKeys: { + listWebhooks: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description API key list */ + /** @description Webhook list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiKeyListResponse"]; + "application/json": components["schemas"]["WebhookListResponse"]; }; }; /** @description Validation error */ @@ -5426,15 +12410,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5455,45 +12430,26 @@ interface operations { }; }; }; - createApiKey: { + createWebhook: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateApiKeyBody"]; + "application/json": components["schemas"]["CreateWebhook"]; }; }; responses: { - /** @description API key created; the secret is behind the reveal link. */ + /** @description Webhook created */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @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. */ - data: components["schemas"]["ApiKey"] & { - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookCreateResponse"]; }; }; /** @description Validation error */ @@ -5543,27 +12499,24 @@ interface operations { }; }; }; - revokeApiKey: { + getWebhook: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ id: string; - /** @description API key id. */ - keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key revoked */ + /** @description Webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; /** @description Validation error */ @@ -5622,43 +12575,24 @@ interface operations { }; }; }; - rotateApiKey: { + deleteWebhook: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ id: string; - /** @description API key id. */ - keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key rotated; the new secret is behind the reveal link. */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - lastFour: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; /** @description Validation error */ @@ -5717,26 +12651,28 @@ interface operations { }; }; }; - listSuppressions: { + updateWebhook: { parameters: { - query?: { - limit?: number; - cursor?: string; - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebhook"]; + }; + }; responses: { - /** @description Suppression list */ + /** @description Webhook updated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionListResponse"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; /** @description Validation error */ @@ -5766,6 +12702,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5786,26 +12731,24 @@ interface operations { }; }; }; - addSuppression: { + rotateWebhookSecret: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddSuppression"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Suppression added */ - 201: { + /** @description Secret rotated */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Suppression"]; + "application/json": components["schemas"]["WebhookRotateSecretResponse"]; }; }; /** @description Validation error */ @@ -5835,6 +12778,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5855,25 +12807,27 @@ interface operations { }; }; }; - checkSuppression: { + listWebhookCalls: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + }; header?: never; path: { - /** @description URL-encoded email address */ - email: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression check result */ + /** @description Webhook call history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionCheckResponse"]; + "application/json": components["schemas"]["WebhookCallsListResponse"]; }; }; /** @description Validation error */ @@ -5903,6 +12857,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5923,511 +12886,475 @@ interface operations { }; }; }; - removeSuppression: { + v1ListWebhooks: { parameters: { - query?: never; - header?: never; - path: { - /** @description URL-encoded email address */ - email: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression removed */ - 204: { + /** @description Webhook list */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["WebhookV1List"]; + }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listTemplates: { + v1CreateWebhook: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - type?: "MARKETING" | "TRANSACTIONAL" | "HEADLESS"; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Create"]; + }; + }; responses: { - /** @description Template list */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TemplateListResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The created webhook and its one-time signing secret */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1Created"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createTemplate: { + v1GetWebhook: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateTemplate"]; + path: { + /** @description Resource id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Template created */ - 201: { + /** @description The webhook */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getTemplate: { + v1DeleteWebhook: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Template */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteTemplate: { + v1UpdateWebhook: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Update"]; + }; + }; responses: { - /** @description Template deleted */ + /** @description The updated webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Template still in use */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateTemplate: { + v1RotateWebhookSecret: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateTemplate"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated template */ + /** @description The new signing secret and the moment the previous one stops verifying */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1SecretRotated"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - trackEvent: { + listApiKeys: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TrackEvent"]; + path: { + /** @description Project id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Event tracked */ + /** @description API key list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TrackEventResponse"]; + "application/json": components["schemas"]["ApiKeyListResponse"]; }; }; /** @description Validation error */ @@ -6457,6 +13384,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -6477,33 +13413,45 @@ interface operations { }; }; }; - createProject: { + createApiKey: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": { - name: string; - /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. - * @enum {string} - */ - sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; + "application/json": components["schemas"]["CreateApiKeyBody"]; }; }; responses: { - /** @description Project created */ + /** @description API key created; the secret is behind the reveal link. */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectRecord"]; + "application/json": { + /** @enum {boolean} */ + success: true; + /** @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. */ + data: components["schemas"]["ApiKey"] & { + /** + * Format: uri + * @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. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + }; + }; }; }; /** @description Validation error */ @@ -6533,15 +13481,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -6562,154 +13501,186 @@ interface operations { }; }; }; - v1GetCampaignAnalytics: { + rotateApiKey: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description API key rotated; the new secret is behind the reveal link. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + lastFour: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetAnalyticsTimeseries: { + revokeApiKey: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Daily time series */ + /** @description API key revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListTopCampaigns: { + listSuppressions: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; limit?: number; + cursor?: string; + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; header?: never; path?: never; @@ -6717,326 +13688,287 @@ interface operations { }; requestBody?: never; responses: { - /** @description Ranked campaigns */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; + "application/json": components["schemas"]["SuppressionListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListCampaigns: { + addSuppression: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Campaign list */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CampaignV1List"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + requestBody: { + content: { + "application/json": components["schemas"]["AddSuppression"]; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + responses: { + /** @description Suppression added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Suppression"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - v1CreateCampaign: { - parameters: { - query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CampaignV1Create"]; + "application/json": components["schemas"]["Error"]; + }; }; - }; - responses: { - /** @description Campaign created */ - 201: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + }; + checkSuppression: { + parameters: { + query?: never; + header?: never; + path: { + /** @description URL-encoded email address */ + email: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suppression check result */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionCheckResponse"]; }; }; - /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetCampaign: { + removeSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The campaign */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Suppression removed */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + content?: never; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteCampaign: { + v1ListSuppressions: { parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Filter to one reason. Omit for every suppressed address. */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign deleted */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Deleted"]; - }; - }; - /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7057,15 +13989,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7095,38 +14018,26 @@ interface operations { }; }; }; - v1UpdateCampaign: { + v1CreateSuppression: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Update"]; + "application/json": components["schemas"]["SuppressionV1Create"]; }; }; responses: { - /** @description The updated campaign */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ - 400: { + /** @description The suppressed address */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7147,15 +14058,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7185,34 +14087,25 @@ interface operations { }; }; }; - v1CancelCampaign: { + v1GetSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The cancelled campaign */ + /** @description The suppression record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7233,7 +14126,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -7271,34 +14164,25 @@ interface operations { }; }; }; - v1PauseCampaign: { + v1DeleteSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The paused campaign */ + /** @description Address removed from the suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — only a `SENDING` campaign can be paused. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7319,15 +14203,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7357,127 +14232,97 @@ interface operations { }; }; }; - v1ResumeCampaign: { + trackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TrackEvent"]; + }; + }; responses: { - /** @description The resumed campaign */ + /** @description Event tracked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["TrackEventResponse"]; }; }; - /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendCampaign: { + v1ListEvents: { parameters: { - query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only events with this exact name. */ + event_name?: string; }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["CampaignV1Send"]; - }; - }; + requestBody?: never; responses: { - /** @description The campaign, now `SENDING` or `SCHEDULED` */ + /** @description Event list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["EventV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7498,25 +14343,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; @@ -7545,25 +14372,26 @@ interface operations { }; }; }; - v1GetCampaignStats: { + v1TrackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EventTrackV1"]; + }; + }; responses: { - /** @description Campaign statistics */ - 200: { + /** @description Event recorded */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Stats"]; + "application/json": components["schemas"]["EventV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7584,7 +14412,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -7622,29 +14450,22 @@ interface operations { }; }; }; - v1SendEmail: { + v1ListEventNames: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendEmailV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Email queued */ - 202: { + /** @description Event names */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailV1"]; + "application/json": components["schemas"]["EventNamesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7656,7 +14477,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; @@ -7665,25 +14486,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; @@ -7710,37 +14513,29 @@ interface operations { "application/problem+json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1SendTestEmail: { + v1GetEventStats: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendTestEmailV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Test email queued */ - 202: { + /** @description Event counts */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailTestV1"]; + "application/json": components["schemas"]["EventStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7752,7 +14547,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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`. */ 403: { headers: { [name: string]: unknown; @@ -7761,16 +14556,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @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. */ 422: { headers: { [name: string]: unknown; @@ -7779,7 +14565,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 429: { headers: { [name: string]: unknown; @@ -7797,25 +14583,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1ListEvents: { + v1GetAnalyticsTimeseries: { parameters: { query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only events with this exact name. */ - event_name?: string; + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; header?: never; path?: never; @@ -7823,13 +14599,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Event list */ + /** @description Daily time series */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1List"]; + "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7879,26 +14655,27 @@ interface operations { }; }; }; - v1TrackEvent: { + v1GetCampaignAnalytics: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["EventTrackV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Event recorded */ - 201: { + /** @description Campaign statistics */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1"]; + "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7919,15 +14696,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no contact with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7957,22 +14725,28 @@ interface operations { }; }; }; - v1ListEventNames: { + v1ListTopCampaigns: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + limit?: number; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Event names */ + /** @description Ranked campaigns */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventNamesV1"]; + "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8022,13 +14796,15 @@ interface operations { }; }; }; - v1GetEventStats: { - parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + v1DiagnoseDeliverability: { + parameters: { + query: { + /** @description A sending domain in this project, e.g. `example.com`. */ + domain: 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. */ + address?: string; + /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ + window_days?: number; }; header?: never; path?: never; @@ -8036,13 +14812,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Event counts */ + /** @description The diagnosis, with findings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStatsV1"]; + "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8092,22 +14868,30 @@ interface operations { }; }; }; - v1GetProject: { + v1ListRecipientDomainStats: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ + days?: number; + /** @description Restrict to one recipient domain. */ + domain?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The authenticated project */ + /** @description Cursor-paginated recipient-domain rollup */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectV1"]; + "application/json": components["schemas"]["RecipientDomainStatsV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8128,15 +14912,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — the project was deleted between authentication and this read. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -8166,12 +14941,16 @@ interface operations { }; }; }; - v1ListSegments: { + v1ListDmarcReports: { parameters: { query?: { limit?: number; /** @description Opaque cursor from a previous response's `next_cursor`. */ after?: string; + /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ + days?: number; + /** @description Restrict to reports about one of your domains. */ + domain?: string; }; header?: never; path?: never; @@ -8179,13 +14958,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Segment list */ + /** @description Cursor-paginated DMARC aggregate reports */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1List"]; + "application/json": components["schemas"]["DmarcReportV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8235,35 +15014,22 @@ interface operations { }; }; }; - v1CreateSegment: { + v1GetUsage: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SegmentV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description Segment created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SegmentV1"]; - }; - }; - /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ - 400: { + /** @description Current usage */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["UsageV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8313,25 +15079,22 @@ interface operations { }; }; }; - v1GetSegment: { + v1GetProject: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The segment */ + /** @description The authenticated project */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": components["schemas"]["ProjectV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8352,7 +15115,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — the project was deleted between authentication and this read. */ 404: { headers: { [name: string]: unknown; @@ -8390,812 +15153,962 @@ interface operations { }; }; }; - v1DeleteSegment: { + listMailboxes: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment deleted */ + /** @description Mailbox list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1Deleted"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Mailbox"][]; + }; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the segment is still used by one or more active campaigns. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateSegment: { + createMailbox: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SegmentV1Update"]; + "application/json": components["schemas"]["CreateMailboxBody"]; }; }; responses: { - /** @description The updated segment */ - 200: { + /** @description Mailbox provisioned */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Mailbox"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegmentContacts: { + getMailbox: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment member list */ + /** @description Mailbox with connection settings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentContactV1List"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["MailboxDetail"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetUsage: { + deleteMailbox: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Current usage */ + /** @description Mailbox deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UsageV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + deleted: true; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflows: { + draftMailboxMessage: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DraftMailboxMessage"]; + }; + }; responses: { - /** @description Workflow list */ + /** @description A draft. Nothing was sent. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1List"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @description Suggested subject, or null. */ + subject: string | null; + /** @description Suggested plain-text body, or null. */ + body: string | null; + /** @description Alternative subject lines (`subject` mode); empty otherwise. */ + subjects: string[]; + /** + * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. + * @enum {boolean} + */ + sent: false; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The drafting model was unreachable or returned nothing usable. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateWorkflow: { + sendMailboxMessage: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowCreateV1"]; + "application/json": components["schemas"]["ComposeMailboxMessage"]; }; }; responses: { - /** @description Workflow created */ + /** @description Message submitted */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + submitted: true; + /** + * Format: uuid + * @description The conversation this send started. Replies thread onto it. + */ + conversationId: string; + /** + * Format: uuid + * @description The stored outbound message. + */ + messageId: string; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The mail server refused the submission. Nothing was sent. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelWorkflowExecution: { + listAppPasswords: { parameters: { query?: never; header?: never; path: { - /** @description Workflow execution id. */ - execution_id: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Cancelled execution */ + /** @description App password list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["AppPassword"][]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no execution with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflow: { + createAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateAppPassword"]; + }; + }; responses: { - /** @description Workflow */ - 200: { + /** @description App password created; the secret is behind the one-time link */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["AppPasswordReveal"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteWorkflow: { + revokeAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; + passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow deleted */ + /** @description App password revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowDeletedV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + revoked: true; + }; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow still has running executions. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateWorkflow: { + createProject: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowUpdateV1"]; + "application/json": { + name: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; + }; }; }; responses: { - /** @description Updated workflow */ - 200: { + /** @description Project created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["ProjectRecord"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the trigger cannot be changed while executions are running. */ - 409: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + }; + }; + verifyEmailAddress: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VerifyEmail"]; + }; + }; + responses: { + /** @description Verification result */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["VerifyEmailResponse"]; }; }; - /** @description `internal_error`. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflowExecutions: { + v1ListTopics: { parameters: { query?: { + cursor?: string; limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only executions in this state. */ - status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + include_archived?: boolean | null; }; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Execution list */ + /** @description One page of topics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1List"]; + "application/json": components["schemas"]["TopicListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9216,15 +16129,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9254,29 +16158,26 @@ interface operations { }; }; }; - v1StartWorkflowExecution: { + v1CreateTopic: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowExecutionStartV1"]; + "application/json": components["schemas"]["TopicCreateV1"]; }; }; responses: { - /** @description Execution started */ + /** @description The created topic */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9297,24 +16198,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9344,28 +16227,25 @@ interface operations { }; }; }; - v1GetWorkflowStats: { + v1GetTopic: { parameters: { - query?: { - /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ - from?: string | null; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description The topic. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow statistics */ + /** @description The topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStatsV1"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9386,15 +16266,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9424,565 +16295,492 @@ interface operations { }; }; }; - verifyEmailAddress: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["VerifyEmail"]; - }; - }; - responses: { - /** @description Verification result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VerifyEmailResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - }; - }; - listWebhooks: { + v1UpdateTopic: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicUpdateV1"]; + }; + }; responses: { - /** @description Webhook list */ + /** @description The updated topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookListResponse"]; + "application/json": components["schemas"]["TopicV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createWebhook: { + v1SetTopicSubscription: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateWebhook"]; + "application/json": components["schemas"]["TopicSubscribeV1"]; }; }; responses: { - /** @description Webhook created */ - 201: { + /** @description The resulting subscription */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCreateResponse"]; + "application/json": components["schemas"]["TopicSubscriptionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getWebhook: { + v1GetContactTopicPreferences: { parameters: { query?: never; header?: never; path: { + /** @description The contact. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook */ + /** @description The contact's preferences */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ContactTopicPreferencesV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteWebhook: { + v1ValidateEmails: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + }; + }; responses: { - /** @description Webhook deleted */ + /** @description One verdict per address, in the order they were given */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationBatchV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateWebhook: { + v1StartListValidationRun: { parameters: { query?: never; header?: never; path: { + /** @description The list to validate. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook updated */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The run, accepted and queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhookCalls: { + v1GetValidationRun: { parameters: { - query?: { - limit?: number; - cursor?: string; - }; + query?: never; header?: never; path: { + /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook call history */ + /** @description The run */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCallsListResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateWebhookSecret: { + v1ListValidationRunResults: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ + verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; + }; header?: never; path: { + /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Secret rotated */ + /** @description One page of results */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookRotateSecretResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationResultListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; @@ -10009,7 +16807,23 @@ type BatchSendResponse = components["schemas"]["BatchSendResponse"]; type BatchEntryResult = components["schemas"]["BatchEntryResult"]; type EmailRecord = components["schemas"]["Email"]; type EmailListResponse = components["schemas"]["EmailListResponse"]; -type EmailGetResponse = components["schemas"]["EmailGetResponse"]; +/** + * 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. + */ +type EmailEvent = components["schemas"]["EmailEvent"]; +/** An email together with its delivery history, oldest first. */ +type EmailWithEvents = components["schemas"]["EmailWithEvents"]; +/** + * A single email with no history — what `emails.cancelSchedule` resolves. + * + * Was `EmailGetResponse` in 1.0, which named the operation rather than the + * shape and was then reused by an operation that is not a GET. + */ +type EmailResponse = components["schemas"]["EmailResponse"]; +/** `emails.get` — one email plus its delivery events. */ +type EmailDetailResponse = components["schemas"]["EmailDetailResponse"]; type ListEmailsQuery = NonNullable; type ContactRecord = components["schemas"]["Contact"]; type ContactListResponse = components["schemas"]["ContactListResponse"]; @@ -10021,6 +16835,8 @@ type ListContactsQuery = NonNullable; type SuppressionRecord = components["schemas"]["Suppression"]; type SuppressionListResponse = components["schemas"]["SuppressionListResponse"]; type SuppressionCheckResponse = components["schemas"]["SuppressionCheckResponse"]; @@ -10069,8 +16901,8 @@ type CampaignV1 = components["schemas"]["CampaignV1"]; type CampaignListV1 = components["schemas"]["CampaignV1List"]; type CampaignDeletedV1 = components["schemas"]["CampaignV1Deleted"]; type CampaignStatsV1 = components["schemas"]["CampaignV1Stats"]; -/** `type` defaults to `MARKETING` server-side, so it is optional here. */ -type CreateCampaignV1Request = PartialKeys; +/** `email_category` defaults to `MARKETING` server-side, so it is optional here. */ +type CreateCampaignV1Request = PartialKeys; type UpdateCampaignV1Request = components["schemas"]["CampaignV1Update"]; type SendCampaignV1Request = components["schemas"]["CampaignV1Send"]; type ListCampaignsV1Query = NonNullable; @@ -10116,6 +16948,92 @@ type EmailTestV1 = components["schemas"]["EmailTestV1"]; type AnalyticsTimeseriesV1Query = NonNullable; type AnalyticsCampaignsV1Query = NonNullable; type ListTopCampaignsV1Query = NonNullable; +type ContactV1 = components["schemas"]["ContactV1"]; +type ContactListV1 = components["schemas"]["ContactV1List"]; +type ContactDeletedV1 = components["schemas"]["ContactV1Deleted"]; +/** `subscribed` defaults to `true` server-side, so it is optional here. */ +type CreateContactV1Request = PartialKeys; +type UpdateContactV1Request = components["schemas"]["ContactV1Update"]; +/** Everything one contact has said they want, topic by topic. */ +type ContactTopicPreferencesV1 = components["schemas"]["ContactTopicPreferencesV1"]; +type ListContactsV1Query = NonNullable; +type ListV1 = components["schemas"]["ListV1"]; +type ListListV1 = components["schemas"]["ListV1List"]; +type ListDeletedV1 = components["schemas"]["ListV1Deleted"]; +/** `double_opt_in` defaults to `false` server-side, so it is optional here. */ +type CreateListV1Request = PartialKeys; +type UpdateListV1Request = components["schemas"]["ListV1Update"]; +type ListListsV1Query = NonNullable; +type TemplateV1 = components["schemas"]["TemplateV1"]; +type TemplateListV1 = components["schemas"]["TemplateV1List"]; +type TemplateDeletedV1 = components["schemas"]["TemplateV1Deleted"]; +/** `email_category` defaults to `MARKETING` server-side, so it is optional here. */ +type CreateTemplateV1Request = PartialKeys; +type UpdateTemplateV1Request = components["schemas"]["TemplateV1Update"]; +type ListTemplatesV1Query = NonNullable; +type DomainV1 = components["schemas"]["DomainV1"]; +type DomainListV1 = components["schemas"]["DomainV1List"]; +type DomainDeletedV1 = components["schemas"]["DomainV1Deleted"]; +type CreateDomainV1Request = components["schemas"]["DomainV1Create"]; +type ListDomainsV1Query = NonNullable; +type WebhookV1 = components["schemas"]["WebhookV1"]; +type WebhookListV1 = components["schemas"]["WebhookV1List"]; +type WebhookDeletedV1 = components["schemas"]["WebhookV1Deleted"]; +/** The create response, and the only time the signing secret is readable. */ +type WebhookCreatedV1 = components["schemas"]["WebhookV1Created"]; +/** Rotation answers the new secret once, for the same reason. */ +type WebhookSecretRotatedV1 = components["schemas"]["WebhookV1SecretRotated"]; +type CreateWebhookV1Request = components["schemas"]["WebhookV1Create"]; +type UpdateWebhookV1Request = components["schemas"]["WebhookV1Update"]; +type ListWebhooksV1Query = NonNullable; +type SuppressionV1 = components["schemas"]["SuppressionV1"]; +type SuppressionListV1 = components["schemas"]["SuppressionV1List"]; +type SuppressionDeletedV1 = components["schemas"]["SuppressionV1Deleted"]; +/** `reason` defaults to `MANUAL` server-side, so it is optional here. */ +type CreateSuppressionV1Request = PartialKeys; +type ListSuppressionsV1Query = NonNullable; +type TopicV1 = components["schemas"]["TopicV1"]; +type TopicListV1 = components["schemas"]["TopicListV1"]; +type CreateTopicV1Request = components["schemas"]["TopicCreateV1"]; +type UpdateTopicV1Request = components["schemas"]["TopicUpdateV1"]; +type SetTopicSubscriptionV1Request = components["schemas"]["TopicSubscribeV1"]; +type TopicSubscriptionV1 = components["schemas"]["TopicSubscriptionV1"]; +type TopicSubscriptionStatusV1 = components["schemas"]["TopicSubscriptionStatusV1"]; +type ListTopicsV1Query = NonNullable; +type ValidateEmailsV1Request = components["schemas"]["EmailValidationBatchRequestV1"]; +type EmailValidationBatchV1 = components["schemas"]["EmailValidationBatchV1"]; +type EmailValidationV1 = components["schemas"]["EmailValidationV1"]; +type EmailValidationVerdictV1 = components["schemas"]["EmailValidationVerdictV1"]; +type EmailValidationRunV1 = components["schemas"]["EmailValidationRunV1"]; +type EmailValidationResultListV1 = components["schemas"]["EmailValidationResultListV1"]; +/** + * One address's verdict inside a run's results — a validation plus the + * `contact_id` it came from. The spec composes it inline rather than naming a + * component, so it is read off the page it appears in. + */ +type EmailValidationResultV1 = EmailValidationResultListV1["data"][number]; +type ListValidationResultsV1Query = NonNullable; +type DeliverabilityDiagnosisV1 = components["schemas"]["DeliverabilityDiagnosisV1"]; +type DeliverabilityFindingV1 = components["schemas"]["DeliverabilityFindingV1"]; +type DeliverabilityFindingSeverityV1 = components["schemas"]["DeliverabilityFindingSeverityV1"]; +type DeliverabilityIdentityV1 = components["schemas"]["DeliverabilityIdentityV1"]; +type DeliverabilityRecentDeliveryV1 = components["schemas"]["DeliverabilityRecentDeliveryV1"]; +type DeliverabilitySuppressionV1 = components["schemas"]["DeliverabilitySuppressionV1"]; +type RecipientDomainStatsV1 = components["schemas"]["RecipientDomainStatsV1"]; +type RecipientDomainStatsListV1 = components["schemas"]["RecipientDomainStatsV1List"]; +type DmarcReportV1 = components["schemas"]["DmarcReportV1"]; +type DmarcReportListV1 = components["schemas"]["DmarcReportV1List"]; +type DiagnoseDeliverabilityV1Query = NonNullable; +type ListRecipientDomainStatsV1Query = NonNullable; +type ListDmarcReportsV1Query = NonNullable; +type CampaignFailureV1 = components["schemas"]["CampaignV1Failure"]; +type CampaignFailureListV1 = components["schemas"]["CampaignV1FailureList"]; +type CampaignRetryFailedV1 = components["schemas"]["CampaignV1RetryFailed"]; +type ListCampaignFailuresV1Query = NonNullable; +type WorkflowGraphV1 = components["schemas"]["WorkflowGraphV1"]; +type ReplaceWorkflowGraphV1Request = components["schemas"]["WorkflowGraphReplaceV1"]; +type CloneWorkflowV1Request = components["schemas"]["WorkflowCloneV1"]; +type WorkflowStateChangeV1 = components["schemas"]["WorkflowStateChangeV1"]; /** * Sending analytics on the `/api/v1` surface. @@ -10200,8 +17118,43 @@ declare class CampaignsResource { resume(id: string): Promise; /** Delivery and engagement counters plus derived rates for one campaign. */ stats(id: string): Promise; + /** + * The recipients this campaign did not reach, and why. + * + * {@link 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 it is `null` on rows recorded before + * reasons were captured. + * + * Cursor-paginated like every other v1 list, but uniquely it also carries + * `total`: {@link retryFailed} acts on that number, and `has_more` alone + * cannot tell you whether 3 or 30,000 sends failed. + */ + listFailures(id: string, query?: ListCampaignFailuresV1Query): Promise; + /** Iterate every failed send across pages, yielding one recipient at a time. */ + listFailuresAll(id: string, query?: ListCampaignFailuresV1Query): AsyncGenerator; + /** + * 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, not re-sent. + * + * The walk runs in the background, so this resolves 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. + */ + retryFailed(id: string): Promise; } +/** + * 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. + */ declare class ContactsResource { private readonly client; constructor(client: Sendly); @@ -10221,8 +17174,132 @@ declare class ContactsResource { update(id: string, body: UpdateContactRequest): Promise; /** Delete a contact. The API answers 200 with `{ success, data: { id } }`; the SDK resolves void. */ delete(id: string): Promise; + /** + * List contacts on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after` with no total count, narrowed by + * `search` (case-insensitive substring on the address) and `subscribed`. + * Hold the filters steady for the whole walk — the cursor encodes them, and + * changing one mid-pagination returns `422 validation_error` asking you to + * restart. {@link listAllV1} drives the loop for you. + */ + listV1(query?: ListContactsV1Query): Promise; + /** Iterate every v1 contact across pages, yielding one contact at a time. */ + listAllV1(query?: ListContactsV1Query): AsyncGenerator; + /** + * Create a contact. Only `email` is required — `subscribed` defaults to true + * server-side, and `custom_fields` is arbitrary JSON that templates can read + * back as `{{ variables }}`. + */ + createV1(body: CreateContactV1Request): Promise; + /** + * Retrieve a single contact by id. v1 has no lookup-by-address route — reach + * a contact you only know the email of through {@link listV1}'s `search`. + */ + getV1(id: string): Promise; + /** + * Patch a contact. Only the fields you send are changed, with two caveats. + * + * `email` is not patchable at all: an address is the contact's identity here, + * 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. Sending a partial object silently drops the rest. + */ + updateV1(id: string, body: UpdateContactV1Request): Promise; + /** + * Delete a contact. Unlike the legacy {@link delete}, this resolves the + * `{ id, deleted }` acknowledgement rather than discarding it. + */ + deleteV1(id: string): Promise; + /** + * 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 nothing 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. + */ + topicPreferences(id: string): Promise; +} + +/** + * Deliverability on the `/api/v1` surface — why mail from your domains is, or + * is not, arriving. + * + * Responses are bare v1 bodies (no `{ success, data }` envelope) and errors are + * RFC 9457 problem documents. + */ +declare class DeliverabilityResource { + private readonly client; + constructor(client: Sendly); + /** + * Diagnose one of your SENDING domains: 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. + * + * `query.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. + */ + diagnose(query: DiagnoseDeliverabilityV1Query): Promise; + /** + * Delivery outcomes broken out 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 {@link 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. + */ + listDomainStats(query?: ListRecipientDomainStatsV1Query): Promise; + /** Iterate every recipient-domain row across pages, one day-and-domain at a time. */ + listDomainStatsAll(query?: ListRecipientDomainStatsV1Query): AsyncGenerator; + /** + * DMARC aggregate (RUA) reports that receiving providers have sent about your + * domains, newest reporting 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). + * + * `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. + */ + listDmarcReports(query?: ListDmarcReportsV1Query): Promise; + /** Iterate every DMARC report across pages, one report at a time. */ + listDmarcReportsAll(query?: ListDmarcReportsV1Query): AsyncGenerator; } +/** + * 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. + */ declare class DomainsResource { private readonly client; constructor(client: Sendly); @@ -10254,8 +17331,77 @@ declare class DomainsResource { * back the link, not to model the flow behind it. */ startSetup(id: string): Promise; + /** + * 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: null` 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. + */ + assignStream(id: string, body: AssignDomainStreamRequest): Promise; /** Delete a domain. */ delete(id: string): Promise; + /** + * List sending domains, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. {@link listAllV1} + * drives the loop 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. + */ + listV1(query?: ListDomainsV1Query): Promise; + /** Iterate every sending domain across pages, yielding one domain at a time. */ + listAllV1(query?: ListDomainsV1Query): AsyncGenerator; + /** + * 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 {@link verifyV1} 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. + */ + createV1(body: CreateDomainV1Request): Promise; + /** Retrieve a single sending domain. */ + getV1(id: string): Promise; + /** + * Re-read the domain's state from SES and DNS, and resolve the refreshed + * document. + * + * 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. + */ + verifyV1(id: string): Promise; + /** + * Remove a sending domain. Resolves `{ id, deleted }`. + * + * 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. + */ + deleteV1(id: string): Promise; } declare class EmailsResource { @@ -10301,10 +17447,24 @@ declare class EmailsResource { batch(body: BatchSendRequest, opts?: IdempotencyOptions): Promise; /** List emails with cursor-based pagination + filters. */ list(query?: ListEmailsQuery): Promise; - /** Fetch a single email and its delivery events. */ - get(id: string): Promise; - /** Cancel a scheduled (PENDING) email before it fires. */ - cancelSchedule(id: string): Promise; + /** + * 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 along with it. + */ + get(id: string): Promise; + /** + * Cancel a scheduled (PENDING) email before it fires. + * + * Resolves the email itself, not an empty acknowledgement: the contract has + * always published `EmailResponse` here, and the caller wants the row's new + * status more than it wants a `{ success: true }` it already inferred from the + * absence of an exception. + */ + cancelSchedule(id: string): Promise; } /** @@ -10360,8 +17520,14 @@ declare class EventsResource { } /** - * Subscription management for a mailing list, on the legacy `/api/*` surface - * (envelope responses, camelCase — the SDK unwraps to `data`). + * Subscriber lists, on both surfaces. + * + * {@link subscribe} and {@link unsubscribe} speak the legacy `/api/*` dialect + * (camelCase inside a `{ success, data }` envelope the SDK unwraps) and accept + * SENDING_ONLY keys. The `V1`-suffixed methods manage the lists themselves on + * `/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. */ declare class ListsResource { private readonly client; @@ -10373,8 +17539,8 @@ declare class ListsResource { * **Double opt-in.** When the list has `doubleOptIn` enabled the membership * is created as `PENDING` and the result carries a `confirmToken`. Sendly * does **not** send the confirmation email — your application must deliver - * `/api/lists/confirm?token=` to the contact itself. The token - * is valid for 24 hours. + * `/api/lists/confirm-subscription?token=` to the contact + * itself. The token is valid for 24 hours. * * **Re-subscribing after an opt-out.** If the email already holds an * `UNSUBSCRIBED` membership on this list, the call fails with @@ -10390,22 +17556,70 @@ declare class ListsResource { subscribe(id: string, body: ListSubscribeRequest): Promise; /** Unsubscribe a contact from a list. Resolves the address that was removed. */ unsubscribe(id: string, body: ListUnsubscribeRequest): Promise; + /** + * List the project's subscriber lists on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold the + * arguments steady for the whole walk — changing them mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + listV1(query?: ListListsV1Query): Promise; + /** Iterate every list across pages, yielding one list at a time. */ + listAllV1(query?: ListListsV1Query): AsyncGenerator; + /** + * Create a list. Only `name` is required; `double_opt_in` defaults to false. + * + * Turning double opt-in on does not make Sendly send anything — it only + * changes {@link subscribe} to create the membership as `PENDING` and hand + * back the `confirmToken` your application delivers. + */ + createV1(body: CreateListV1Request): Promise; + /** + * Retrieve 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. + */ + getV1(id: string): Promise; + /** + * 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. + */ + updateV1(id: string, body: UpdateListV1Request): Promise; + /** Delete a list. Resolves `{ id, deleted }`. Removes the list, not its contacts. */ + deleteV1(id: string): Promise; + /** + * 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.getRun`. + */ + startValidationRun(id: string): Promise; } +/** What {@link MailboxesResource.sendMessage} resolves once the message is submitted. */ +type MailboxMessageSubmitted = paths["/api/mailboxes/{id}/messages"]["post"]["responses"][201]["content"]["application/json"]["data"]; +/** What {@link MailboxesResource.draftMessage} resolves — suggested text, and `sent: false`. */ +type MailboxMessageDraft = paths["/api/mailboxes/{id}/drafts"]["post"]["responses"][200]["content"]["application/json"]["data"]; /** - * Receiving mailboxes on the project's verified domains. + * Receiving mailboxes on the project's verified domains, plus the two + * composition operations an API key may drive. * - * READ ONLY, and deliberately so. Creating and deleting a mailbox, and minting - * or revoking an app password, all resolve the acting project admin from the - * session user; an API key carries no user, so those routes answer `401` to any - * `sk_` key however broad its scopes. The contract records that — they publish - * `SessionAuth` without `ApiKeyAuth` — and this SDK authenticates only with API - * keys, so a `create`/`delete` here could never succeed. They are listed in the - * contract suite's `NOT_SDK_CALLABLE` rather than shipped as methods that - * always throw. + * MAILBOX LIFECYCLE is what stays out of reach: creating and deleting a + * mailbox, and minting or revoking an app password, all resolve the acting + * project admin from the session user; an API key carries no user, so those + * routes answer `401` to any `sk_` key however broad its scopes. The contract + * records that — they publish `SessionAuth` without `ApiKeyAuth` — and this SDK + * authenticates only with API keys, so a `create`/`delete` here could never + * succeed. They are listed in the contract suite's `NOT_SDK_CALLABLE` rather + * than shipped as methods that always throw. * - * The three reads below are a different case: their membership check is - * conditional, so a key really can call them. + * Everything below is a different case — the reads' membership check is + * conditional, and {@link sendMessage} / {@link draftMessage} publish + * `ApiKeyAuth` outright — so a key really can call them. */ declare class MailboxesResource { private readonly client; @@ -10441,6 +17655,43 @@ declare class MailboxesResource { * this can identify a credential without being able to reconstruct it. */ listAppPasswords(id: string): Promise; + /** + * 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. + */ + sendMessage(id: string, body: ComposeMailboxMessageRequest): Promise; + /** + * 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 {@link sendMessage} + * 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. + */ + draftMessage(id: string, body: DraftMailboxMessageRequest): Promise; } /** @@ -10502,6 +17753,46 @@ declare class SegmentsResource { listContactsAll(id: string, query?: ListSegmentContactsV1Query): AsyncGenerator; } +/** + * Snippets — 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. + */ +declare class SnippetsResource { + private readonly client; + constructor(client: Sendly); + /** + * Create a snippet. `name` is the literal identifier templates include with + * `{{> name}}` and is unique within the project, so a clash answers 409. + */ + create(body: CreateSnippetRequest): Promise; + /** List snippets with cursor pagination (`limit`/`cursor`) + optional `search` over name and description. */ + list(query?: ListSnippetsQuery): Promise; + /** Fetch a single snippet by id. */ + get(id: string): Promise; + /** Patch an existing snippet. */ + update(id: string, body: UpdateSnippetRequest): Promise; + /** + * Delete a snippet. The API answers 200 with `{ success, data: { id } }`; the + * SDK resolves void. Templates that still include it keep rendering — an + * absent snippet renders as an empty string, like an absent variable. + */ + delete(id: string): Promise; +} + +/** + * The project suppression list — the addresses no send may reach — in both + * dialects. + * + * 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. + */ declare class SuppressionResource { private readonly client; constructor(client: Sendly); @@ -10513,14 +17804,63 @@ declare class SuppressionResource { get(email: string): Promise; /** Remove an email from the suppression list. Returns 204. */ remove(email: string): Promise; + /** + * List suppressed addresses, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold `reason` + * steady for the whole walk — changing it mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + listV1(query?: ListSuppressionsV1Query): Promise; + /** Iterate every suppressed address across pages, yielding one record at a time. */ + listAllV1(query?: ListSuppressionsV1Query): AsyncGenerator; + /** + * 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. + */ + createV1(body: CreateSuppressionV1Request): Promise; + /** + * Retrieve 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. + */ + getV1(email: string): Promise; + /** + * Un-suppress an address: mail can flow to it again. Resolves + * `{ email, deleted }`. + * + * 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. + */ + deleteV1(email: string): Promise; } +/** + * 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. + */ declare class TemplatesResource { private readonly client; constructor(client: Sendly); /** Create a reusable email template. */ create(body: CreateTemplateRequest): Promise; - /** List templates with cursor pagination (`limit`/`cursor`) + optional type filter. */ + /** List templates with cursor pagination (`limit`/`cursor`) + optional `emailCategory` filter. */ list(query?: ListTemplatesQuery): Promise; /** Fetch a single template by id. */ get(id: string): Promise; @@ -10528,6 +17868,114 @@ declare class TemplatesResource { update(id: string, body: UpdateTemplateRequest): Promise; /** Delete a template. The API answers 200 with `{ success, data: { id } }` (409 if still referenced); the SDK resolves void. */ delete(id: string): Promise; + /** + * List templates, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. `search` here + * matches the name only — narrower than the dashboard's search, which also + * reads description and subject. Hold `search` and `email_category` steady + * for the whole walk; changing either mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + listV1(query?: ListTemplatesV1Query): Promise; + /** Iterate every template across pages, yielding one template at a time. */ + listAllV1(query?: ListTemplatesV1Query): AsyncGenerator; + /** + * Create a template. `email_category` defaults to `MARKETING` server-side. + * + * 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. + */ + createV1(body: CreateTemplateV1Request): Promise; + /** Retrieve a single template. */ + getV1(id: string): Promise; + /** + * Patch a template. Only the fields you send are changed. + * + * 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. + */ + updateV1(id: string, body: UpdateTemplateV1Request): Promise; + /** + * Delete a template. Resolves `{ id, deleted }` — the legacy `delete` above + * discards that body, 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. + */ + deleteV1(id: string): Promise; +} + +/** + * Topics on the `/api/v1` surface — 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. + */ +declare class TopicsResource { + private readonly client; + constructor(client: Sendly); + /** + * List topics, newest first. + * + * Archived topics are omitted unless `include_archived` asks for them. There + * is no delete — archiving is the retire button, because a topic is where + * people's answers are recorded. {@link listAll} drives the loop for you. + * + * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + */ + list(query?: ListTopicsV1Query): Promise; + /** + * Iterate every topic across pages, yielding one topic at a time. + * + * The walk is written out here rather than delegated to `paginateCursor` + * because this endpoint names its cursor `cursor` on both sides — the query + * parameter and the response field — where every other v1 list takes `after` + * and answers `next_cursor`. + */ + listAll(query?: ListTopicsV1Query): AsyncGenerator; + /** + * Create a topic. + * + * `key` is the stable name every preference form and integration refers to, + * so it survives a rename of `name` and cannot be changed afterwards. + * + * `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. + */ + create(body: CreateTopicV1Request): Promise; + /** Retrieve a single topic. */ + get(id: string): Promise; + /** + * Patch a topic. Only the fields you send are changed. + * + * `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. + */ + update(id: string, body: UpdateTopicV1Request): Promise; + /** + * Record what one contact wants on one topic. The two directions are not + * symmetric, on purpose. + * + * `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. + */ + setSubscription(id: string, body: SetTopicSubscriptionV1Request): Promise; } /** @@ -10548,6 +17996,65 @@ declare class UsageResource { get(): Promise; } +/** + * Email validation on the `/api/v1` surface — check addresses before you mail + * them, and read back what a bulk run found. + * + * Responses are bare v1 bodies (no `{ success, data }` envelope) and errors are + * RFC 9457 problem documents. + */ +declare class ValidationResource { + private readonly client; + constructor(client: Sendly); + /** + * 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 + * (`lists.startValidationRun`) 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. + */ + validateEmails(body: ValidateEmailsV1Request): Promise; + /** + * Retrieve a bulk validation run: how far it has got, and what it found. + * + * The other way a run starts is `lists.startValidationRun`, 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. + */ + getRun(id: string): Promise; + /** + * 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. + * + * This list pages on `cursor`, not the `after` every other v1 collection + * takes, and its envelope carries the next page under `cursor` rather than + * `next_cursor`. {@link listResultsAll} drives that loop for you. + */ + listResults(id: string, query?: ListValidationResultsV1Query): Promise; + /** + * Iterate every result across pages, yielding one address's verdict at a time. + * + * Hand-rolled rather than routed through `paginateCursor`: the shared helper + * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on + * both sides, so the helper would send an ignored parameter and re-fetch page + * one forever. Stops on `has_more: false`, a null cursor, or a cursor the + * server repeats. + */ + listResultsAll(id: string, query?: ListValidationResultsV1Query): AsyncGenerator; +} + declare class VerifyResource { private readonly client; constructor(client: Sendly); @@ -10563,6 +18070,15 @@ type ListWebhookCallsQuery = { limit?: number; cursor?: string; }; +/** + * Webhook endpoints, 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. + */ declare class WebhooksResource { private readonly client; constructor(client: Sendly); @@ -10584,6 +18100,64 @@ declare class WebhooksResource { rotateSecret(id: string): Promise; /** List recent delivery attempts for a webhook. */ listCalls(id: string, query?: ListWebhookCallsQuery): Promise; + /** + * List webhook endpoints, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. + * {@link listAllV1} drives the loop for you. Signing secrets are not on this + * response — see {@link rotateSecretV1} if you have lost one. + */ + listV1(query?: ListWebhooksV1Query): Promise; + /** Iterate every webhook endpoint across pages, yielding one endpoint at a time. */ + listAllV1(query?: ListWebhooksV1Query): AsyncGenerator; + /** + * Register an endpoint to receive HMAC-signed deliveries for the events named + * in `event_types`. + * + * Resolves `{ webhook, secret }`, and this is one of only two calls that ever + * carry the signing secret — {@link rotateSecretV1} 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 + * `verifySignature` to authenticate the deliveries that arrive at your + * endpoint. + */ + createV1(body: CreateWebhookV1Request): Promise; + /** Retrieve a single webhook endpoint. The signing secret is not on this response. */ + getV1(id: string): Promise; + /** + * 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. + */ + updateV1(id: string, body: UpdateWebhookV1Request): Promise; + /** + * 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. Resolves `{ id, deleted }`. Deliveries already in flight are not + * recalled, so the endpoint may still receive an event shortly after this. + */ + deleteV1(id: string): Promise; + /** + * Mint a fresh signing secret for an endpoint. + * + * The new plaintext is returned exactly once, here — this and + * {@link createV1} 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 + * `verifySignature`. 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. + */ + rotateSecretV1(id: string): Promise; } /** @@ -10642,10 +18216,73 @@ declare class WorkflowsResource { * `{ from }`; there is no 90-day ceiling here, unlike `analytics.*`. */ stats(id: string, query?: WorkflowStatsV1Query): Promise; + /** + * Every step in the workflow — including its `TRIGGER` entry node — 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 {@link replaceGraph} — read, edit one step, + * send it back. + */ + getGraph(id: string): Promise; + /** + * 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. {@link pause} first. + */ + replaceGraph(id: string, body: ReplaceWorkflowGraphV1Request): Promise; + /** + * 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 `. + */ + clone(id: string, body: CloneWorkflowV1Request): Promise; + /** + * Disable the workflow *and cancel every `RUNNING`/`WAITING` execution in it*, + * resolving `{ 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: {@link resume} re-opens the workflow to new + * runs, it does not put the cancelled contacts back where they were. + */ + pause(id: string): Promise; + /** + * 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. + */ + resume(id: string): Promise; } /** Build-time package version (kept in sync with package.json). */ -declare const SDK_VERSION = "1.0.0"; +declare const SDK_VERSION = "1.1.0"; /** Default production API base. Override via `baseUrl` for staging or self-hosted deployments. */ declare const DEFAULT_BASE_URL = "https://api.sendly.now"; interface SendlyClientOptions { @@ -10663,8 +18300,14 @@ interface SendlyClientOptions { interface RequestOptions { /** Path relative to baseUrl, must start with `/`. */ path: string; - /** HTTP method. */ - method: "GET" | "POST" | "PATCH" | "DELETE"; + /** + * HTTP method. + * + * `PUT` exists for exactly one operation — replacing a workflow graph — and the + * distinction is the point: a graph is replaced whole, never patched, because a + * partial edit to a node list has no meaning without the edges that reference it. + */ + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; /** Optional JSON body. Will be serialized + Content-Type set. */ body?: unknown; /** @@ -10699,6 +18342,8 @@ declare class Sendly { readonly events: EventsResource; readonly verify: VerifyResource; readonly lists: ListsResource; + /** Reusable body fragments a template includes with `{{> name}}`. */ + readonly snippets: SnippetsResource; /** Receiving mailboxes. Reads only — the writes need a user, not an API key. */ readonly mailboxes: MailboxesResource; /** Campaigns on the versioned `/api/v1` surface. */ @@ -10713,6 +18358,12 @@ declare class Sendly { readonly usage: UsageResource; /** The project this key belongs to, on the versioned `/api/v1` surface. */ readonly projects: ProjectsResource; + /** Consent topics and what each contact has said they want. */ + readonly topics: TopicsResource; + /** Address validation — one batch, or a whole list. */ + readonly validation: ValidationResource; + /** Why mail from your domains is or is not arriving. */ + readonly deliverability: DeliverabilityResource; private readonly apiKey; private readonly baseUrl; private readonly fetchImpl; @@ -10940,4 +18591,4 @@ declare function verifySignature(payload: string | Buffer, signature: string, ti */ declare function constructEvent>(payload: string | Buffer, signature: string, timestamp: string, secret: string, options?: VerifySignatureOptions): T; -export { type AddDomainRequest, type AddSuppressionRequest, type AnalyticsCampaignStatsV1, type AnalyticsCampaignsV1Query, AnalyticsResource, type AnalyticsTimeseriesV1, type AnalyticsTimeseriesV1Query, type AnalyticsTopCampaignsV1, type AnalyticsWindowV1, type AppPasswordRecord, type BatchEntryResult, type BatchSendRequest, type BatchSendResponse, type BulkCreateContactsRequest, type BulkDeleteContactsRequest, type CampaignDeletedV1, type CampaignListV1, type CampaignStatsV1, type CampaignV1, CampaignsResource, type ContactListResponse, type ContactRecord, ContactsResource, type CreateCampaignV1Request, type CreateContactRequest, type CreateSegmentV1Request, type CreateTemplateRequest, type CreateWebhookRequest, type CreateWorkflowV1Request, type CursorPage, type CursorPageQuery, DEFAULT_BASE_URL, DEFAULT_TOLERANCE_MS, type DomainListResponse, type DomainRecord, type DomainSetupSession, type DomainVerificationStatus, DomainsResource, type EmailGetResponse, type EmailListResponse, type EmailRecord, type EmailTestV1, type EmailV1, EmailsResource, type ErrorEnvelope, type EventListV1, type EventNamesV1, type EventStatsV1, type EventStatsV1Query, type EventV1, EventsResource, type IdResponse, type IdempotencyOptions, type ListCampaignsV1Query, type ListContactsQuery, type ListEmailsQuery, type ListEventsV1Query, type ListSegmentContactsV1Query, type ListSegmentsV1Query, type ListSubscribeData, type ListSubscribeRequest, type ListSubscribeResponse, type ListSuppressionsQuery, type ListTemplatesQuery, type ListTopCampaignsV1Query, type ListUnsubscribeData, type ListUnsubscribeRequest, type ListUnsubscribeResponse, type ListWebhookCallsQuery, type ListWorkflowExecutionsV1Query, type ListWorkflowsV1Query, ListsResource, type MailboxDetail, type MailboxRecord, MailboxesResource, type Problem, type ProblemDocument, type ProblemFieldError, type ProjectV1, ProjectsResource, type RecordEventV1Request, type RequestOptions, SDK_VERSION, type SegmentContactListV1, type SegmentContactV1, type SegmentDeletedV1, type SegmentListV1, type SegmentV1, SegmentsResource, type SendCampaignV1Request, type SendEmailData, type SendEmailRequest, type SendEmailResponse, type SendEmailV1Request, type SendTestEmailV1Request, Sendly, SendlyAuthenticationError, type SendlyClientOptions, SendlyConflictError, SendlyConnectionError, SendlyError, SendlyNotFoundError, SendlyPermissionError, SendlyRateLimitError, SendlyServerError, SendlyValidationError, type StartWorkflowExecutionV1Request, type SuccessEmpty, type SuppressionCheckResponse, type SuppressionListResponse, type SuppressionRecord, SuppressionResource, type TemplateListResponse, type TemplateRecord, TemplatesResource, type TrackEventData, type TrackEventRequest, type TrackEventResponse, type UpdateCampaignV1Request, type UpdateContactRequest, type UpdateSegmentV1Request, type UpdateTemplateRequest, type UpdateWebhookRequest, type UpdateWorkflowV1Request, UsageResource, type UsageV1, type VerifyEmailData, type VerifyEmailRequest, type VerifyEmailResponse, VerifyResource, type VerifySignatureOptions, type WebhookCall, type WebhookCallsListResponse, type WebhookCreateResponse, type WebhookGetResponse, type WebhookListResponse, type WebhookRecord, type WebhookRotateSecretResponse, WebhooksResource, type WorkflowDeletedV1, type WorkflowExecutionListV1, type WorkflowExecutionV1, type WorkflowListV1, type WorkflowStatsV1, type WorkflowStatsV1Query, type WorkflowV1, WorkflowsResource, asProblemDocument, type components, constructEvent, type operations, paginateCursor, type paths, verifySignature }; +export { type AddDomainRequest, type AddSuppressionRequest, type AnalyticsCampaignStatsV1, type AnalyticsCampaignsV1Query, AnalyticsResource, type AnalyticsTimeseriesV1, type AnalyticsTimeseriesV1Query, type AnalyticsTopCampaignsV1, type AnalyticsWindowV1, type AppPasswordRecord, type AssignDomainStreamRequest, type BatchEntryResult, type BatchSendRequest, type BatchSendResponse, type BulkCreateContactsRequest, type BulkDeleteContactsRequest, type CampaignDeletedV1, type CampaignFailureListV1, type CampaignFailureV1, type CampaignListV1, type CampaignRetryFailedV1, type CampaignStatsV1, type CampaignV1, CampaignsResource, type CloneWorkflowV1Request, type ComposeMailboxMessageRequest, type ContactDeletedV1, type ContactListResponse, type ContactListV1, type ContactRecord, type ContactTopicPreferencesV1, type ContactV1, ContactsResource, type CreateCampaignV1Request, type CreateContactRequest, type CreateContactV1Request, type CreateDomainV1Request, type CreateListV1Request, type CreateSegmentV1Request, type CreateSnippetRequest, type CreateSuppressionV1Request, type CreateTemplateRequest, type CreateTemplateV1Request, type CreateTopicV1Request, type CreateWebhookRequest, type CreateWebhookV1Request, type CreateWorkflowV1Request, type CursorPage, type CursorPageQuery, DEFAULT_BASE_URL, DEFAULT_TOLERANCE_MS, type DeliverabilityDiagnosisV1, type DeliverabilityFindingSeverityV1, type DeliverabilityFindingV1, type DeliverabilityIdentityV1, type DeliverabilityRecentDeliveryV1, DeliverabilityResource, type DeliverabilitySuppressionV1, type DiagnoseDeliverabilityV1Query, type DmarcReportListV1, type DmarcReportV1, type DomainDeletedV1, type DomainListResponse, type DomainListV1, type DomainRecord, type DomainSetupSession, type DomainV1, type DomainVerificationStatus, DomainsResource, type DraftMailboxMessageRequest, type EmailDetailResponse, type EmailEvent, type EmailListResponse, type EmailRecord, type EmailResponse, type EmailTestV1, type EmailV1, type EmailValidationBatchV1, type EmailValidationResultListV1, type EmailValidationResultV1, type EmailValidationRunV1, type EmailValidationV1, type EmailValidationVerdictV1, type EmailWithEvents, EmailsResource, type ErrorEnvelope, type EventListV1, type EventNamesV1, type EventStatsV1, type EventStatsV1Query, type EventV1, EventsResource, type IdResponse, type IdempotencyOptions, type ListCampaignFailuresV1Query, type ListCampaignsV1Query, type ListContactsQuery, type ListContactsV1Query, type ListDeletedV1, type ListDmarcReportsV1Query, type ListDomainsV1Query, type ListEmailsQuery, type ListEventsV1Query, type ListListV1, type ListListsV1Query, type ListRecipientDomainStatsV1Query, type ListSegmentContactsV1Query, type ListSegmentsV1Query, type ListSnippetsQuery, type ListSubscribeData, type ListSubscribeRequest, type ListSubscribeResponse, type ListSuppressionsQuery, type ListSuppressionsV1Query, type ListTemplatesQuery, type ListTemplatesV1Query, type ListTopCampaignsV1Query, type ListTopicsV1Query, type ListUnsubscribeData, type ListUnsubscribeRequest, type ListUnsubscribeResponse, type ListV1, type ListValidationResultsV1Query, type ListWebhookCallsQuery, type ListWebhooksV1Query, type ListWorkflowExecutionsV1Query, type ListWorkflowsV1Query, ListsResource, type MailboxDetail, type MailboxRecord, MailboxesResource, type Problem, type ProblemDocument, type ProblemFieldError, type ProjectV1, ProjectsResource, type RecipientDomainStatsListV1, type RecipientDomainStatsV1, type RecordEventV1Request, type ReplaceWorkflowGraphV1Request, type RequestOptions, SDK_VERSION, type SegmentContactListV1, type SegmentContactV1, type SegmentDeletedV1, type SegmentListV1, type SegmentV1, SegmentsResource, type SendCampaignV1Request, type SendEmailData, type SendEmailRequest, type SendEmailResponse, type SendEmailV1Request, type SendTestEmailV1Request, Sendly, SendlyAuthenticationError, type SendlyClientOptions, SendlyConflictError, SendlyConnectionError, SendlyError, SendlyNotFoundError, SendlyPermissionError, SendlyRateLimitError, SendlyServerError, SendlyValidationError, type SetTopicSubscriptionV1Request, type SnippetListResponse, type SnippetRecord, SnippetsResource, type StartWorkflowExecutionV1Request, type SuccessEmpty, type SuppressionCheckResponse, type SuppressionDeletedV1, type SuppressionListResponse, type SuppressionListV1, type SuppressionRecord, SuppressionResource, type SuppressionV1, type TemplateDeletedV1, type TemplateListResponse, type TemplateListV1, type TemplateRecord, type TemplateV1, TemplatesResource, type TopicListV1, type TopicSubscriptionStatusV1, type TopicSubscriptionV1, type TopicV1, TopicsResource, type TrackEventData, type TrackEventRequest, type TrackEventResponse, type UpdateCampaignV1Request, type UpdateContactRequest, type UpdateContactV1Request, type UpdateListV1Request, type UpdateSegmentV1Request, type UpdateSnippetRequest, type UpdateTemplateRequest, type UpdateTemplateV1Request, type UpdateTopicV1Request, type UpdateWebhookRequest, type UpdateWebhookV1Request, type UpdateWorkflowV1Request, UsageResource, type UsageV1, type ValidateEmailsV1Request, ValidationResource, type VerifyEmailData, type VerifyEmailRequest, type VerifyEmailResponse, VerifyResource, type VerifySignatureOptions, type WebhookCall, type WebhookCallsListResponse, type WebhookCreateResponse, type WebhookCreatedV1, type WebhookDeletedV1, type WebhookGetResponse, type WebhookListResponse, type WebhookListV1, type WebhookRecord, type WebhookRotateSecretResponse, type WebhookSecretRotatedV1, type WebhookV1, WebhooksResource, type WorkflowDeletedV1, type WorkflowExecutionListV1, type WorkflowExecutionV1, type WorkflowGraphV1, type WorkflowListV1, type WorkflowStateChangeV1, type WorkflowStatsV1, type WorkflowStatsV1Query, type WorkflowV1, WorkflowsResource, asProblemDocument, type components, constructEvent, type operations, paginateCursor, type paths, verifySignature }; diff --git a/dist/index.d.ts b/dist/index.d.ts index 4c7ad0b..7e54f05 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ interface paths { - "/api/contacts": { + "/api/v1/campaigns": { parameters: { query?: never; header?: never; @@ -11,55 +11,69 @@ interface paths { cookie?: never; }; /** - * List contacts - * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. + * List campaigns + * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - get: operations["listContacts"]; + get: operations["v1ListCampaigns"]; put?: never; /** - * Create a contact - * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. + * Create a campaign + * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * The `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. + * + * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["createContact"]; + post: operations["v1CreateCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/bulk": { + "/api/v1/campaigns/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Bulk-create contacts - * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. + * Retrieve a campaign + * @description Fetch one campaign, including its materialized delivery counters. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["bulkCreateContacts"]; + get: operations["v1GetCampaign"]; + put?: never; + post?: never; /** - * Bulk-delete contacts - * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. + * Delete a campaign + * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["bulkDeleteContacts"]; + delete: operations["v1DeleteCampaign"]; options?: never; head?: never; - patch?: never; + /** + * Update a campaign + * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * + * Changing `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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + */ + patch: operations["v1UpdateCampaign"]; trace?: never; }; - "/api/contacts/upsert": { + "/api/v1/campaigns/{id}/send": { parameters: { query?: never; header?: never; @@ -69,131 +83,115 @@ interface paths { get?: never; put?: never; /** - * Create or update a contact by email - * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. + * Send or schedule a campaign + * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * **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. + * + * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * + * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. */ - post: operations["upsertContact"]; + post: operations["v1SendCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/{id}": { + "/api/v1/campaigns/{id}/cancel": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a contact - * @description Requires the `contacts:read` scope — View your contacts and their custom fields. - */ - get: operations["getContact"]; + get?: never; put?: never; - post?: never; /** - * Delete a contact - * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). + * Cancel a campaign + * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["deleteContact"]; + post: operations["v1CancelCampaign"]; + delete?: never; options?: never; head?: never; - /** - * Update a contact - * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - patch: operations["updateContact"]; + patch?: never; trace?: never; }; - "/api/domains": { + "/api/v1/campaigns/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List sending domains - * @description List all domains for the authenticated project. - * - * Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["listDomains"]; + get?: never; put?: never; /** - * Add a sending domain - * @description Register a new domain with SES and persist its DKIM tokens. + * Pause a sending campaign + * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["addDomain"]; + post: operations["v1PauseCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}": { + "/api/v1/campaigns/{id}/resume": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a sending domain - * @description Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["getDomain"]; + get?: never; put?: never; - post?: never; /** - * Remove a sending domain - * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. + * Resume a paused campaign + * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["deleteDomain"]; + post: operations["v1ResumeCampaign"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/dodomain-session": { + "/api/v1/campaigns/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Start guided DNS setup - * @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. - * - * The 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. + * Retrieve campaign statistics + * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. * - * `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. + * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["startDomainSetup"]; + get: operations["v1GetCampaignStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/verify": { + "/api/v1/campaigns/{id}/failures": { parameters: { query?: never; header?: never; @@ -201,79 +199,83 @@ interface paths { cookie?: never; }; /** - * Read SES verification status - * @description Read the current SES verification status without forcing a refresh. + * List a campaign's failed sends + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - get: operations["getDomainVerification"]; - put?: never; - /** - * Trigger SES verification - * @description Force a refresh of the domain's SES verification status. + * `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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Cursor-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. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["verifyDomain"]; + get: operations["v1ListCampaignFailures"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails": { + "/api/v1/campaigns/{id}/retry-failed": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List emails - * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. - * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. - */ - get: operations["listEmails"]; + get?: never; put?: never; /** - * Send a single transactional email - * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * Retry a campaign's failed sends + * @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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * The 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. + * + * Only 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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["sendEmail"]; + post: operations["v1RetryCampaignFailures"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/batch": { + "/api/v1/segments": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List segments + * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * + * `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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. + */ + get: operations["v1ListSegments"]; put?: never; /** - * Send a batch of emails - * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. + * Create a segment + * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. * - * The 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. + * A `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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - post: operations["sendEmailBatch"]; + post: operations["v1CreateSegment"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}": { + "/api/v1/segments/{id}": { parameters: { query?: never; header?: never; @@ -281,89 +283,129 @@ interface paths { cookie?: never; }; /** - * Get a single email - * @description Fetch one email along with its delivery events. + * Retrieve a segment + * @description Fetch one segment, including its saved `condition` and materialized `member_count`. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["getEmail"]; + get: operations["v1GetSegment"]; put?: never; post?: never; - delete?: never; + /** + * Delete a segment + * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + delete: operations["v1DeleteSegment"]; options?: never; head?: never; - patch?: never; + /** + * Update a segment + * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * + * `type` is not accepted here — see the create operation. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + patch: operations["v1UpdateSegment"]; trace?: never; }; - "/api/emails/{id}/schedule": { + "/api/v1/segments/{id}/contacts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Cancel a scheduled (still-PENDING) email - * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. + * List the contacts in a segment + * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. + * + * Cursors 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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - delete: operations["cancelScheduledEmail"]; + get: operations["v1ListSegmentContacts"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/lists/{id}/subscribe": { + "/api/v1/workflows": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Subscribe a contact to a list - * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm?token=` to the contact itself. + * List workflows + * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. * - * **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. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1ListWorkflows"]; + put?: never; + /** + * Create a workflow + * @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`. * - * `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. + * Pass `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["subscribeToList"]; + post: operations["v1CreateWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/lists/{id}/unsubscribe": { + "/api/v1/workflows/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Retrieve a workflow + * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. + * + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1GetWorkflow"]; put?: never; + post?: never; /** - * Unsubscribe a contact from a list - * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. + * Delete a workflow + * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. * - * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["unsubscribeFromList"]; - delete?: never; + delete: operations["v1DeleteWorkflow"]; options?: never; head?: never; - patch?: never; + /** + * Update a workflow + * @description Sparse update — omitted fields are left unchanged. + * + * Two 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. + * + * `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + patch: operations["v1UpdateWorkflow"]; trace?: never; }; - "/api/mailboxes": { + "/api/v1/workflows/{id}/executions": { parameters: { query?: never; header?: never; @@ -371,69 +413,75 @@ interface paths { cookie?: never; }; /** - * List mailboxes - * @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. - * - * This 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. + * List a workflow's executions + * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listMailboxes"]; + get: operations["v1ListWorkflowExecutions"]; put?: never; /** - * Create a mailbox - * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. - * - * Three consequences worth knowing before you call it: - * - * - **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. - * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. - * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. - * - * Retrying 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. + * Start a workflow for a contact + * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. * - * `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. + * 409 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createMailbox"]; + post: operations["v1StartWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}": { + "/api/v1/workflows/executions/{execution_id}/cancel": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get a mailbox - * @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. + * Cancel a workflow execution + * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - get: operations["getMailbox"]; - put?: never; - post?: never; + post: operations["v1CancelWorkflowExecution"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/{id}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; /** - * Delete a mailbox - * @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. + * Retrieve workflow statistics + * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. * - * Requires an admin of the project. + * The 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - delete: operations["deleteMailbox"]; + get: operations["v1GetWorkflowStats"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords": { + "/api/v1/workflows/{id}/graph": { parameters: { query?: never; header?: never; @@ -441,33 +489,37 @@ interface paths { cookie?: never; }; /** - * List a mailbox's app passwords - * @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. + * Retrieve a workflow's step graph + * @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. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * A 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. + * + * The 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. + * + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listAppPasswords"]; - put?: never; + get: operations["v1GetWorkflowGraph"]; /** - * Create an app password - * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. + * Replace a workflow's step graph + * @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. * - * **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. + * A 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. * - * That 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. + * Refused 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. * - * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * The 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createAppPassword"]; + put: operations["v1ReplaceWorkflowGraph"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords/{passwordId}": { + "/api/v1/workflows/{id}/clone": { parameters: { query?: never; header?: never; @@ -476,52 +528,48 @@ interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an app password - * @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. + * Clone a workflow + * @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. * - * Requires an admin of the project. An API key is refused with 401. + * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeAppPassword"]; + post: operations["v1CloneWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys": { + "/api/v1/workflows/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List API keys for a project - * @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. - * - * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. - */ - get: operations["listApiKeys"]; + get?: never; put?: never; /** - * Create an API key - * @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. + * Pause a workflow and cancel its running executions + * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. * - * **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. + * `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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createApiKey"]; + post: operations["v1PauseWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}": { + "/api/v1/workflows/{id}/resume": { parameters: { query?: never; header?: never; @@ -530,20 +578,22 @@ interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an API key - * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * Resume a paused workflow + * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Refused 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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeApiKey"]; + post: operations["v1ResumeWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}/rotate": { + "/api/v1/emails": { parameters: { query?: never; header?: never; @@ -553,47 +603,55 @@ interface paths { get?: never; put?: never; /** - * Rotate an API key's secret - * @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`). + * Send a transactional email + * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * This 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. + * + * Exactly 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. + * + * `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. + * + * An 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - post: operations["rotateApiKey"]; + post: operations["v1SendEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression": { + "/api/v1/emails/test": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List suppressed emails - * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. - * - * Requires the `suppression:read` scope — View the addresses on your suppression list. - */ - get: operations["listSuppressions"]; + get?: never; put?: never; /** - * Manually add an email to the suppression list - * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * Send a sandbox test email + * @description Prove that sending works — before any domain, DNS record or verification exists. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * The 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. + * + * That 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. + * + * Sandbox 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. + * + * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. */ - post: operations["addSuppression"]; + post: operations["v1SendTestEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression/{email}": { + "/api/emails": { parameters: { query?: never; header?: never; @@ -601,27 +659,27 @@ interface paths { cookie?: never; }; /** - * Check whether an email is suppressed - * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * List emails + * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["checkSuppression"]; + get: operations["listEmails"]; put?: never; - post?: never; /** - * Remove an email from the suppression list - * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * Send a single transactional email + * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["removeSuppression"]; + post: operations["sendEmail"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates": { + "/api/emails/{id}": { parameters: { query?: never; header?: never; @@ -629,59 +687,47 @@ interface paths { cookie?: never; }; /** - * List templates - * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. + * Get a single email + * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. * - * Requires the `templates:read` scope — View your email templates. - */ - get: operations["listTemplates"]; - put?: never; - /** - * Create a template - * @description Create a new email template. The `from` domain must already be verified for the project. + * `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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - post: operations["createTemplate"]; + get: operations["getEmail"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates/{id}": { + "/api/emails/batch": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a template - * @description Requires the `templates:read` scope — View your email templates. - */ - get: operations["getTemplate"]; + get?: never; put?: never; - post?: never; /** - * Delete a template - * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * Send a batch of emails + * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * The 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["deleteTemplate"]; + post: operations["sendEmailBatch"]; + delete?: never; options?: never; head?: never; - /** - * Update a template - * @description Update one or more fields. If `from` changes, the new domain must already be verified. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateTemplate"]; + patch?: never; trace?: never; }; - "/api/track": { + "/api/emails/{id}/schedule": { parameters: { query?: never; header?: never; @@ -690,94 +736,96 @@ interface paths { }; get?: never; put?: never; + post?: never; /** - * Track a custom event for a contact - * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. - * - * Requires the `events:write` scope — Record custom events for your contacts. + * Cancel a scheduled (still-PENDING) email + * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. */ - post: operations["trackEvent"]; - delete?: never; + delete: operations["cancelScheduledEmail"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/users/me/projects": { + "/api/contacts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create a project - * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * List contacts + * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. * - * Preconditions 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. + * Requires the `contacts:read` scope — View your contacts and their custom fields. + */ + get: operations["listContacts"]; + put?: never; + /** + * Create a contact + * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. * - * Requires the `projects:write` scope — Create new projects on your account. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["createProject"]; + post: operations["createContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/campaigns": { + "/api/contacts/upsert": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve campaign totals and engagement - * @description Campaign counts plus average open and click rates. - * - * `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. - * - * `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. + * Create or update a contact by email + * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetCampaignAnalytics"]; - put?: never; - post?: never; + post: operations["upsertContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/timeseries": { + "/api/contacts/bulk": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve the daily email time series - * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * Bulk-create contacts + * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. * - * `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. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + post: operations["bulkCreateContacts"]; + /** + * Bulk-delete contacts + * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetAnalyticsTimeseries"]; - put?: never; - post?: never; - delete?: never; + delete: operations["bulkDeleteContacts"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/top-campaigns": { + "/api/contacts/{id}": { parameters: { query?: never; header?: never; @@ -785,23 +833,31 @@ interface paths { cookie?: never; }; /** - * List the best-performing campaigns - * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. - * - * `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. - * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Get a contact + * @description Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListTopCampaigns"]; + get: operations["getContact"]; put?: never; post?: never; - delete?: never; + /** + * Delete a contact + * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + delete: operations["deleteContact"]; options?: never; head?: never; - patch?: never; + /** + * Update a contact + * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + patch: operations["updateContact"]; trace?: never; }; - "/api/v1/campaigns": { + "/api/v1/contacts": { parameters: { query?: never; header?: never; @@ -809,33 +865,29 @@ interface paths { cookie?: never; }; /** - * List campaigns - * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * List contacts + * @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. * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * A 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. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListCampaigns"]; + get: operations["v1ListContacts"]; put?: never; /** - * Create a campaign - * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. - * - * The `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. - * - * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * Create a contact + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateCampaign"]; + post: operations["v1CreateContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}": { + "/api/v1/contacts/{id}": { parameters: { query?: never; header?: never; @@ -843,35 +895,37 @@ interface paths { cookie?: never; }; /** - * Retrieve a campaign - * @description Fetch one campaign, including its materialized delivery counters. + * Retrieve a contact + * @description Fetch one contact by id. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1GetCampaign"]; + get: operations["v1GetContact"]; put?: never; post?: never; /** - * Delete a campaign - * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. + * Delete a contact + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteCampaign"]; + delete: operations["v1DeleteContact"]; options?: never; head?: never; /** - * Update a campaign - * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * Update a contact + * @description Partial update. Omitted fields are left alone. * - * Changing `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. + * `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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * `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. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - patch: operations["v1UpdateCampaign"]; + patch: operations["v1UpdateContact"]; trace?: never; }; - "/api/v1/campaigns/{id}/cancel": { + "/api/lists/{id}/subscribe": { parameters: { query?: never; header?: never; @@ -881,21 +935,23 @@ interface paths { get?: never; put?: never; /** - * Cancel a campaign - * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. + * Subscribe a contact to a list + * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. * - * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * **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. + * + * `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. */ - post: operations["v1CancelCampaign"]; + post: operations["subscribeToList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/pause": { + "/api/lists/{id}/unsubscribe": { parameters: { query?: never; header?: never; @@ -905,67 +961,53 @@ interface paths { get?: never; put?: never; /** - * Pause a sending campaign - * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. + * Unsubscribe a contact from a list + * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. */ - post: operations["v1PauseCampaign"]; + post: operations["unsubscribeFromList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/resume": { + "/api/v1/lists": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Resume a paused campaign - * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. + * List subscriber lists + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * `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. + * + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - post: operations["v1ResumeCampaign"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/campaigns/{id}/send": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; + get: operations["v1ListLists"]; put?: never; /** - * Send or schedule a campaign - * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. + * Create a subscriber list + * @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`. * - * **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. + * **`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. * - * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. * - * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["v1SendCampaign"]; + post: operations["v1CreateList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/stats": { + "/api/v1/lists/{id}": { parameters: { query?: never; header?: never; @@ -973,81 +1015,101 @@ interface paths { cookie?: never; }; /** - * Retrieve campaign statistics - * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. - * - * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. + * Retrieve a subscriber list + * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - get: operations["v1GetCampaignStats"]; + get: operations["v1GetList"]; put?: never; post?: never; - delete?: never; + /** + * Delete a subscriber list + * @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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + delete: operations["v1DeleteList"]; options?: never; head?: never; - patch?: never; + /** + * Update a subscriber list + * @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. + * + * **`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. + * + * Turning `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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + patch: operations["v1UpdateList"]; trace?: never; }; - "/api/v1/emails": { + "/api/domains": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a transactional email - * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. - * - * This 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. - * - * Exactly 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. - * - * `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. + * List sending domains + * @description List all domains for the authenticated project. * - * An 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. + * Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["listDomains"]; + put?: never; + /** + * Add a sending domain + * @description Register a new domain with SES and persist its DKIM tokens. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1SendEmail"]; + post: operations["addDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails/test": { + "/api/domains/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a sandbox test email - * @description Prove that sending works — before any domain, DNS record or verification exists. + * Get a sending domain + * @description Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["getDomain"]; + put?: never; + post?: never; + /** + * Remove a sending domain + * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. * - * The 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. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + delete: operations["deleteDomain"]; + options?: never; + head?: never; + /** + * Assign a sending identity to a stream + * @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. * - * That 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. + * Streams 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. * - * Sandbox 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. + * At 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. * - * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1SendTestEmail"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["assignDomainStream"]; trace?: never; }; - "/api/v1/events": { + "/api/domains/{id}/verify": { parameters: { query?: never; header?: never; @@ -1055,59 +1117,53 @@ interface paths { cookie?: never; }; /** - * List events - * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. - * - * A 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. + * Read SES verification status + * @description Read the current SES verification status without forcing a refresh. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1ListEvents"]; + get: operations["getDomainVerification"]; put?: never; /** - * Record an event - * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. - * - * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. - * - * Reserved 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. - * - * This 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. - * - * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * Trigger SES verification + * @description Force a refresh of the domain's SES verification status. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1TrackEvent"]; + post: operations["verifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events/names": { + "/api/domains/{id}/dodomain-session": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List event names - * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * Start guided DNS setup + * @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. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * The 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. + * + * `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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1ListEventNames"]; - put?: never; - post?: never; + post: operations["startDomainSetup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events/stats": { + "/api/v1/domains": { parameters: { query?: never; header?: never; @@ -1115,23 +1171,35 @@ interface paths { cookie?: never; }; /** - * Retrieve event counts - * @description Per-name event counts over a bounded window, most frequent first. + * List sending domains + * @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. * - * The 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. + * `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. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetEventStats"]; + get: operations["v1ListDomains"]; put?: never; - post?: never; + /** + * Add a sending domain + * @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. + * + * `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. + * + * `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. + * + * A 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["v1CreateDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/projects": { + "/api/v1/domains/{id}": { parameters: { query?: never; header?: never; @@ -1139,57 +1207,55 @@ interface paths { cookie?: never; }; /** - * Retrieve the authenticated project - * @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. - * - * `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). - * - * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * Retrieve a sending domain + * @description Fetch one sending domain by id. * - * Requires the `projects:read` scope — View your projects and their settings. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetProject"]; + get: operations["v1GetDomain"]; put?: never; post?: never; - delete?: never; + /** + * Remove a sending domain + * @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. + * + * The 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + delete: operations["v1DeleteDomain"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments": { + "/api/v1/domains/{id}/verify": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List segments - * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * `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. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. - */ - get: operations["v1ListSegments"]; + get?: never; put?: never; /** - * Create a segment - * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. + * Refresh a sending domain's verification state + * @description Re-read this domain's state from SES and DNS and return the refreshed document. * - * A `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. + * This 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. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1CreateSegment"]; + post: operations["v1VerifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments/{id}": { + "/api/templates": { parameters: { query?: never; header?: never; @@ -1197,35 +1263,27 @@ interface paths { cookie?: never; }; /** - * Retrieve a segment - * @description Fetch one segment, including its saved `condition` and materialized `member_count`. + * List templates + * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetSegment"]; + get: operations["listTemplates"]; put?: never; - post?: never; /** - * Delete a segment - * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. + * Create a template + * @description Create a new email template. The `from` domain must already be verified for the project. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["v1DeleteSegment"]; + post: operations["createTemplate"]; + delete?: never; options?: never; head?: never; - /** - * Update a segment - * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. - * - * `type` is not accepted here — see the create operation. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. - */ - patch: operations["v1UpdateSegment"]; + patch?: never; trace?: never; }; - "/api/v1/segments/{id}/contacts": { + "/api/templates/{id}": { parameters: { query?: never; header?: never; @@ -1233,23 +1291,31 @@ interface paths { cookie?: never; }; /** - * List the contacts in a segment - * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. - * - * Cursors 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. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Get a template + * @description Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListSegmentContacts"]; + get: operations["getTemplate"]; put?: never; post?: never; - delete?: never; + /** + * Delete a template + * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + delete: operations["deleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Update one or more fields. If `from` changes, the new domain must already be verified. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateTemplate"]; trace?: never; }; - "/api/v1/usage": { + "/api/v1/templates": { parameters: { query?: never; header?: never; @@ -1257,28 +1323,31 @@ interface paths { cookie?: never; }; /** - * Retrieve current usage and limits - * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. - * - * Every 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. + * List templates + * @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. * - * Two caveats worth reading before you alert on these numbers: + * `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. * - * - 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. - * - `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`. + * A 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. * - * Requires the `usage:read` scope — View your usage totals and billing limits. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetUsage"]; + get: operations["v1ListTemplates"]; put?: never; - post?: never; + /** + * Create a template + * @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. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + post: operations["v1CreateTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows": { + "/api/v1/templates/{id}": { parameters: { query?: never; header?: never; @@ -1286,51 +1355,65 @@ interface paths { cookie?: never; }; /** - * List workflows - * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * Retrieve a template + * @description Fetch one template by id. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListWorkflows"]; + get: operations["v1GetTemplate"]; put?: never; + post?: never; /** - * Create a workflow - * @description Creates an event-triggered workflow with a single trigger step. The rest of the graph (emails, delays, conditions) is built in the dashboard, so a workflow is created disabled and stays inert until it has steps to run. + * Delete a template + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1CreateWorkflow"]; - delete?: never; + delete: operations["v1DeleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Partial update. Omitted fields are left alone. + * + * Changing `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. + * + * A `from` supplied here is verified before anything is written, on the same terms as create. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["v1UpdateTemplate"]; trace?: never; }; - "/api/v1/workflows/executions/{execution_id}/cancel": { + "/api/snippets": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List snippets + * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. + * + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listSnippets"]; put?: never; /** - * Cancel a workflow execution - * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. + * Create a snippet + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1CancelWorkflowExecution"]; + post: operations["createSnippet"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}": { + "/api/snippets/{id}": { parameters: { query?: never; header?: never; @@ -1338,35 +1421,29 @@ interface paths { cookie?: never; }; /** - * Retrieve a workflow - * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Get a snippet + * @description Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetWorkflow"]; + get: operations["getSnippet"]; put?: never; post?: never; /** - * Delete a workflow - * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. + * Delete a snippet + * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["v1DeleteWorkflow"]; + delete: operations["deleteSnippet"]; options?: never; head?: never; /** - * Update a workflow - * @description Sparse update — omitted fields are left unchanged. - * - * Two 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. - * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Update a snippet + * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - patch: operations["v1UpdateWorkflow"]; + patch: operations["updateSnippet"]; trace?: never; }; - "/api/v1/workflows/{id}/executions": { + "/api/webhooks": { parameters: { query?: never; header?: never; @@ -1374,29 +1451,27 @@ interface paths { cookie?: never; }; /** - * List a workflow's executions - * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * List user webhooks + * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1ListWorkflowExecutions"]; + get: operations["listWebhooks"]; put?: never; /** - * Start a workflow for a contact - * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. - * - * 409 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. + * Create a webhook + * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1StartWorkflowExecution"]; + post: operations["createWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/stats": { + "/api/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -1404,21 +1479,29 @@ interface paths { cookie?: never; }; /** - * Retrieve workflow statistics - * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Get a webhook + * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetWorkflowStats"]; + get: operations["getWebhook"]; put?: never; post?: never; - delete?: never; + /** + * Delete a webhook + * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + delete: operations["deleteWebhook"]; options?: never; head?: never; - patch?: never; + /** + * Update a webhook + * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + patch: operations["updateWebhook"]; trace?: never; }; - "/api/verify": { + "/api/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; @@ -1428,17 +1511,19 @@ interface paths { get?: never; put?: never; /** - * Validate an email address - * @description Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier. + * Rotate the webhook signing secret + * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["verifyEmailAddress"]; + post: operations["rotateWebhookSecret"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks": { + "/api/webhooks/{id}/calls": { parameters: { query?: never; header?: never; @@ -1446,27 +1531,53 @@ interface paths { cookie?: never; }; /** - * List user webhooks - * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). + * List recent webhook calls + * @description Cursor-paginated list of recent delivery attempts for a single webhook. * * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["listWebhooks"]; + get: operations["listWebhookCalls"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List webhooks + * @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. + * + * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + */ + get: operations["v1ListWebhooks"]; put?: never; /** * Create a webhook - * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. + * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. + * + * The 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. * * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["createWebhook"]; + post: operations["v1CreateWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}": { + "/api/v1/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -1474,51 +1585,93 @@ interface paths { cookie?: never; }; /** - * Get a webhook - * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Retrieve a webhook + * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["getWebhook"]; + get: operations["v1GetWebhook"]; put?: never; post?: never; /** * Delete a webhook - * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * @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. * * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - delete: operations["deleteWebhook"]; + delete: operations["v1DeleteWebhook"]; options?: never; head?: never; /** * Update a webhook - * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * @description Partial update. 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 part of this response. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - patch: operations["updateWebhook"]; + patch: operations["v1UpdateWebhook"]; trace?: never; }; - "/api/webhooks/{id}/calls": { + "/api/v1/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List recent webhook calls - * @description Cursor-paginated list of recent delivery attempts for a single webhook. + * Rotate a webhook signing secret + * @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. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Rotation 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. + * + * `url`, `event_types` and `status` are unchanged. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["listWebhookCalls"]; + post: operations["v1RotateWebhookSecret"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{id}/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List API keys for a project + * @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. + * + * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + */ + get: operations["listApiKeys"]; put?: never; - post?: never; + /** + * Create an API key + * @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. + * + * **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. + * + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + */ + post: operations["createApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}/rotate-secret": { + "/api/projects/{id}/api-keys/{keyId}/rotate": { parameters: { query?: never; header?: never; @@ -1528,1624 +1681,8443 @@ interface paths { get?: never; put?: never; /** - * Rotate the webhook signing secret - * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * Rotate an API key's secret + * @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`). * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["rotateWebhookSecret"]; + post: operations["rotateApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; -} -interface components { - schemas: { - /** @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. */ - AddDomainBody: { - domain: string; - /** Format: uuid */ - projectId?: string; - /** - * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. - * @enum {string} - */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; - /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ - AddSuppression: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; - /** @description Campaign counters and engagement over the window. */ - AnalyticsCampaignStatsV1: { - /** @description Campaigns in DRAFT or SCHEDULED. */ - active: number; - average_click_rate: number; - /** @description Percentage, one decimal place. */ - average_open_rate: number; - completed: number; - total: number; - window: components["schemas"]["AnalyticsWindowV1"]; + "/api/projects/{id}/api-keys/{keyId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ - AnalyticsTimeseriesV1: { - data: { - bounces: number; - clicks: number; - /** Format: date-time */ - date: string; - delivered: number; - emails: number; - opens: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + get?: never; + put?: never; + post?: never; + /** + * Revoke an API key + * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + */ + delete: operations["revokeApiKey"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/suppression": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Sent campaigns ranked by open rate. */ - AnalyticsTopCampaignsV1: { - data: { - click_rate: number; - clicked: number; - /** Format: uuid */ - id: string; - open_rate: number; - opened: number; - sent: number; - subject: string; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** + * List suppressed emails + * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["listSuppressions"]; + put?: never; + /** + * Manually add an email to the suppression list + * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + post: operations["addSuppression"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/suppression/{email}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check whether an email is suppressed + * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["checkSuppression"]; + put?: never; + post?: never; + /** + * Remove an email from the suppression list + * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["removeSuppression"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List suppressed addresses + * @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. + * + * A 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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1ListSuppressions"]; + put?: never; + /** + * Suppress an address + * @description Add an address to this project's suppression list, so no further send reaches it. + * + * Idempotent: 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. + * + * `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. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + post: operations["v1CreateSuppression"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions/{email}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check whether an address is suppressed + * @description Fetch the suppression record for one address. The path parameter is the address itself, URL-encoded. + * + * An 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. + * + * A `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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1GetSuppression"]; + put?: never; + post?: never; + /** + * Remove an address from the suppression list + * @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. + * + * It 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. + * + * Idempotent: an address that was never suppressed answers `200` too, because "not on the list" is the state you asked for. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["v1DeleteSuppression"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/track": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Track a custom event for a contact + * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["trackEvent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List events + * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. + * + * A 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1ListEvents"]; + put?: never; + /** + * Record an event + * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. + * + * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * + * Reserved 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. + * + * This 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. + * + * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["v1TrackEvent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events/names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List event names + * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1ListEventNames"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve event counts + * @description Per-name event counts over a bounded window, most frequent first. + * + * The 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1GetEventStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/timeseries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve the daily email time series + * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1GetAnalyticsTimeseries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve campaign totals and engagement + * @description Campaign counts plus average open and click rates. + * + * `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. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1GetCampaignAnalytics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/top-campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the best-performing campaigns + * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1ListTopCampaigns"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/diagnose": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Diagnose why mail from a domain is not arriving + * @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. + * + * Everything 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. + * + * `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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1DiagnoseDeliverability"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Delivery outcomes per recipient domain + * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. + * + * This 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. + * + * The 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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1ListRecipientDomainStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/dmarc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * DMARC aggregate reports for your domains + * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * + * The 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. + * + * `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. + * + * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1ListDmarcReports"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve current usage and limits + * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. + * + * Every 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. + * + * Two caveats worth reading before you alert on these numbers: + * + * - 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. + * - `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`. + * + * Requires the `usage:read` scope — View your usage totals and billing limits. + */ + get: operations["v1GetUsage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve the authenticated project + * @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. + * + * `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). + * + * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * + * Requires the `projects:read` scope — View your projects and their settings. + */ + get: operations["v1GetProject"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List mailboxes + * @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. + * + * This 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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["listMailboxes"]; + put?: never; + /** + * Create a mailbox + * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. + * + * Three consequences worth knowing before you call it: + * + * - **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. + * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. + * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * + * Retrying 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. + * + * `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. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + post: operations["createMailbox"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a mailbox + * @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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["getMailbox"]; + put?: never; + post?: never; + /** + * Delete a mailbox + * @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. + * + * Requires an admin of the project. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + delete: operations["deleteMailbox"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Draft a message with AI + * @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. + * + * **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. + * + * That 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. + * + * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * + * Drafting is capped at 120 requests per hour per project. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + post: operations["draftMailboxMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send a message from a mailbox + * @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. + * + * **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. + * + * **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. + * + * Bcc 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. + * + * Refusals worth handling by name: + * + * - `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. + * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. + * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. + * - `429` — a mailbox may send 60 messages an hour through this endpoint. + * + * The message is stored as a new conversation on the mailbox, so the reply threads onto it. + * + * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. + */ + post: operations["sendMailboxMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/app-passwords": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a mailbox's app passwords + * @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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["listAppPasswords"]; + put?: never; + /** + * Create an app password + * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. + * + * **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. + * + * That 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. + * + * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + post: operations["createAppPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/app-passwords/{passwordId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke an app password + * @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. + * + * Requires an admin of the project. An API key is refused with 401. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + delete: operations["revokeAppPassword"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a project + * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * + * Preconditions 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. + * + * Requires the `projects:write` scope — Create new projects on your account. + */ + post: operations["createProject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate an email address + * @description Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier. + */ + post: operations["verifyEmailAddress"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List topics + * @description The subjects this project mails about, cursor-paginated and newest first. + * + * Archived 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. + * + * `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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1ListTopics"]; + put?: never; + /** + * Create a topic + * @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. + * + * `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`. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + post: operations["v1CreateTopic"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve a topic + * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1GetTopic"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update a topic + * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + patch: operations["v1UpdateTopic"]; + trace?: never; + }; + "/api/v1/topics/{id}/subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Subscribe or unsubscribe a contact from a topic + * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. + * + * `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. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + post: operations["v1SetTopicSubscription"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/contacts/{id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a contact's topic preferences + * @description Everything this contact has said they want, as the send path reads it. + * + * `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. + * + * The 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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1GetContactTopicPreferences"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/email-validations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate a batch of email addresses + * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. + * + * The 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. + * + * `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. + * + * The 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + */ + post: operations["v1ValidateEmails"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/lists/{id}/validation-runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate every address on a list + * @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. + * + * This 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`. + * + * A 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + */ + post: operations["v1StartListValidationRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/validation-runs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve a validation run + * @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. + * + * There 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. + * + * Requires the `validation:read` scope — View your email validation runs and their results. + */ + get: operations["v1GetValidationRun"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/validation-runs/{id}/results": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a validation run's results + * @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. + * + * No 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. + * + * `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. + * + * Requires the `validation:read` scope — View your email validation runs and their results. + */ + get: operations["v1ListValidationRunResults"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +interface components { + schemas: { + /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ + Error: { + /** @enum {boolean} */ + success?: false; + error: { + message: string; + code: string; + details?: { + errors: unknown[]; + }; + }; + }; + /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ + Problem: { + /** + * Format: uri + * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + */ + type: string; + /** @description Short, stable summary — the same for every occurrence of a `type`. */ + title: string; + /** @description HTTP status code, repeated in the body. */ + status: number; + /** @description Explanation specific to this occurrence. */ + detail?: string; + /** @description Request path the failure occurred on. */ + instance?: string; + /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ + code: string; + /** @description Correlation id — quote it in support requests. */ + request_id?: string; + /** @description Field-level failures. Present on 422 `validation_error` responses. */ + errors?: { + /** @description RFC 6901 JSON Pointer to the offending field. */ + pointer: string; + code: string; + message: string; + }[]; + }; + /** @description Bare success envelope with no payload. */ + SuccessEmpty: { + /** @enum {boolean} */ + success: true; + }; + /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ + IdResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + id: string; + }; + }; + /** @description A subscriber/contact within a project. */ + Contact: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: email */ + email: string; + subscribed: boolean; + customFields?: { + [key: string]: unknown; + } | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Contact"][]; + total: number; + /** @description Cursor for the next page, or null on the last page. */ + nextCursor: string | null; + hasMore: boolean; + }; + }; + /** @description A reusable email template. */ + Template: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + description?: string | null; + subject: string; + body: string; + /** Format: email */ + from: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** @enum {string} */ + emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of templates. */ + TemplateListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Template"][]; + total: number; + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + hasMore: boolean; + }; + }; + /** @description A reusable fragment of template markup. */ + Snippet: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** @description The literal identifier a template includes with `{{> name}}`. */ + name: string; + description?: string | null; + /** @description Template markup. Values it interpolates are escaped like any other. */ + body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of snippets. */ + SnippetListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Snippet"][]; + total: number; + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + hasMore: boolean; + }; + }; + /** + * @description Delivery lifecycle of the message. Engagement is reported separately. + * @enum {string} + */ + EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ + Domain: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + verified: boolean; + region?: string | null; + dkim?: { + type: string; + name: string; + value: string; + }[]; + /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + /** + * @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 {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @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). */ + streamDefault?: boolean; + /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ + defaultFromAddress?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description List of all domains for the auth'd project. */ + DomainListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Domain"][]; + }; + /** @description Outcome of a verification check against SES. */ + DomainVerificationStatus: { + verified: boolean; + mxRecords?: string[]; + dkim?: { + type: string; + name: string; + value: string; + }[]; + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + }; + /** @description A sent (or queued) transactional email. */ + Email: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + from: string; + to: string; + subject: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description First open, or null. + */ + openedAt: string | null; + /** + * Format: date-time + * @description First click, or null. + */ + clickedAt: string | null; + /** + * Format: date-time + * @description Spam complaint, or null. + */ + complainedAt: string | null; + /** @description Total opens recorded. */ + opens: number; + /** @description Total clicks recorded. */ + clicks: number; + tags: string[]; + error?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description One transition in a message's delivery history. */ + EmailEvent: { + /** Format: uuid */ + id: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description A transactional email together with its delivery history. */ + EmailWithEvents: components["schemas"]["Email"] & { + /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ + events: components["schemas"]["EmailEvent"][]; + }; + /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ + SendEmailRecipientResult: { + contact: { + /** Format: uuid */ + id: string; + /** Format: email */ + email: string; + }; + /** Format: uuid */ + email: string; + }; + /** @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`. */ + SendEmailData: { + emails: components["schemas"]["SendEmailRecipientResult"][]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @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. */ + SendEmailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["SendEmailData"]; + }; + /** @description Per-row result in a batch send response. */ + BatchEntryResult: { + index: number; + /** @enum {string} */ + status: "ok" | "error"; + data?: components["schemas"]["SendEmailData"]; + error?: { + message: string; + code: string; + }; + }; + /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ + BatchSendResponse: { + success: boolean; + data: components["schemas"]["BatchEntryResult"][]; + }; + /** @description Batch send wrapper. Up to 100 entries. */ + BatchSendBody: { + emails: components["schemas"]["SendEmail"][]; + }; + /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ + SendEmail: { + to: string | { + name?: string; + /** Format: email */ + email: string; + } | (string | { + name?: string; + /** Format: email */ + email: string; + })[]; + subject?: string; + body?: string; + /** Format: uuid */ + template?: string; + subscribed?: boolean; + name?: string; + from?: string | { + name?: string; + /** Format: email */ + email: string; + }; + /** Format: email */ + reply?: string; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + attachments?: { + filename: string; + content: string; + contentType: string; + contentId?: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + }[]; + tags?: string[]; + cc?: string[]; + bcc?: string[]; + }; + /** @description Bulk create up to 1000 contacts. */ + ContactBulkCreateBody: { + contacts: components["schemas"]["CreateContact"][]; + }; + /** @description Body for POST /api/contacts and /api/contacts/upsert. */ + CreateContact: { + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + customFields?: { + [key: string]: unknown; + }; + }; + /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ + ContactBulkDeleteBody: { + ids?: string[]; + emails?: string[]; + }; + /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ + UpdateContactBody: { + subscribed?: boolean; + customFields?: { + [key: string]: unknown; + }; + }; + /** @description A single suppressed-email record. */ + Suppression: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: email */ + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + }; + /** @description Cursor-paginated list of suppressions. */ + SuppressionListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Suppression"][]; + nextCursor?: string | null; + cursor?: string | null; + hasMore?: boolean; + }; + /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ + SuppressionCheckResponse: { + suppressed: boolean; + /** @enum {string} */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt?: string; + }; + /** @description A user-managed outbound webhook. */ + Webhook: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: uri */ + url: string; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + consecutiveFailures: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + disabledAt?: string | null; + lastFour?: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ + WebhookCreateResponse: { + /** @enum {boolean} */ + success: true; + /** @description A user-managed outbound webhook. */ + data: components["schemas"]["Webhook"] & { + /** @description Plaintext shared secret. Returned ONCE on create. */ + secret: string; + }; + }; + /** @description List of webhooks for the auth'd project. */ + WebhookListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Webhook"][]; + }; + /** @description Single webhook (no secret). */ + WebhookGetResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Webhook"]; + }; + /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ + WebhookRotateSecretResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + id: string; + /** @description New plaintext shared secret. */ + secret: string; + }; + }; + /** @description An attempted webhook delivery. */ + WebhookCall: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + webhookId: string; + eventType: string; + payload: { + [key: string]: unknown; + }; + /** @enum {string} */ + status: "PENDING" | "SUCCESS" | "FAILED"; + attempt: number; + responseStatus?: number | null; + responseBody?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + }; + /** @description Cursor-paginated list of recent calls for a single webhook. */ + WebhookCallsListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["WebhookCall"][]; + nextCursor?: string | null; + cursor?: string | null; + hasMore?: boolean; + }; + /** @description Response from POST /api/track. */ + TrackEventResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + contact: string; + /** Format: uuid */ + event: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + }; + /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ + VerifyEmailResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: email */ + email: string; + valid: boolean; + reason?: string; + } & { + [key: string]: unknown; + }; + }; + /** @description Cursor-paginated list of emails. */ + EmailListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Email"][]; + nextCursor?: string | null; + }; + /** @description A single email. */ + EmailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Email"]; + }; + /** @description One email and its delivery history. */ + EmailDetailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["EmailWithEvents"]; + }; + /** @description Result of a list-subscribe call. */ + ListSubscribeResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + membershipId: string; + /** @enum {string} */ + status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; + /** @description True when the membership row did not exist before this call. */ + created: boolean; + /** + * @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 {string|null} + */ + previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; + /** @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. */ + confirmToken?: string; + }; + }; + /** @description Echoes the address that was unsubscribed. */ + ListUnsubscribeResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: email */ + email: string; + }; + }; + /** @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. */ + ApiKey: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ + lastFour: string; + /** + * @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 {string} + */ + legacyGrantPreset: "FULL" | "SENDING_ONLY"; + /** + * @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 {string} + */ + mode: "LIVE" | "TEST"; + /** @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. */ + scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + /** Format: uuid */ + domainId: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + lastUsedAt: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. + */ + revokedAt: string | null; + }; + /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ + ApiKeyListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["ApiKey"][]; + }; + ProjectRecord: { + /** Format: uuid */ + id: string; + name: string; + disabled: boolean; + disabledReason: string | null; + /** @description Local-part of the sandbox quick-start sender; null until first derived. */ + sandboxHandle: string | null; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + billingLimitWorkflows: number | null; + billingLimitCampaigns: number | null; + billingLimitTransactional: number | null; + billingLimitInbound: number | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + sesRegion: string | null; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; + organizationId: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of campaigns. */ + CampaignV1List: { + data: components["schemas"]["CampaignV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A campaign as exposed on the v1 API. */ + CampaignV1: { + /** Format: uuid */ + id: string; + name: string; + /** @enum {string} */ + status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; + subject: string; + /** @enum {string} */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + /** Format: uuid */ + list_id: string | null; + /** Format: uuid */ + topic_id: string | null; + /** Format: date-time */ + scheduled_at: string | null; + /** Format: date-time */ + sent_at: string | null; + /** Format: date-time */ + created_at: string; + stats: { + total_recipients: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + }; + }; + /** @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`. */ + CampaignV1Create: { + name: string; + description?: string; + subject: string; + body: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** + * @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 {string} + */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** Format: uuid */ + segment_id?: string; + /** + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + */ + list_id?: string; + /** + * Format: uuid + * @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. + */ + topic_id?: string | null; + }; + /** @description A filter condition: one or more groups combined with `logic`. */ + FilterConditionV1: { + /** @enum {string} */ + logic: "AND" | "OR"; + groups: components["schemas"]["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. */ + FilterGroupV1: { + filters: components["schemas"]["SegmentFilterV1"][]; + conditions?: components["schemas"]["FilterConditionV1"]; + }; + /** @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). */ + SegmentFilterV1: { + field: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; + value?: unknown; + /** @enum {string} */ + unit?: "days" | "hours" | "minutes"; + }; + /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ + CampaignV1Update: { + name?: string; + description?: string; + subject?: string; + body?: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from?: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** @enum {string} */ + audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** Format: uuid */ + segment_id?: string; + /** + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + */ + list_id?: string; + /** + * Format: uuid + * @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. + */ + topic_id?: string | null; + }; + /** @description Acknowledgement that a campaign was deleted. */ + CampaignV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/v1/campaigns/{id}/send. */ + CampaignV1Send: { + /** + * Format: date-time + * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. + */ + scheduled_for?: string; + }; + /** @description Materialized delivery and engagement counters for one campaign. */ + CampaignV1Stats: { + total_recipients: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + open_rate: number; + click_rate: number; + bounce_rate: number; + delivery_rate: number; + }; + /** @description Cursor-paginated list of a campaign's failed sends. */ + CampaignV1FailureList: { + data: components["schemas"]["CampaignV1Failure"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + /** @description Every FAILED row on this campaign, not just this page. */ + total: number; + }; + /** @description A campaign recipient whose send did not complete. */ + CampaignV1Failure: { + /** + * Format: uuid + * @description Ledger row id. Pass the last one as `after` to page. + */ + id: string; + /** Format: uuid */ + contact_id: string; + /** @description The recipient the send was for. */ + email: string; + reason: string | null; + /** Format: date-time */ + failed_at: string; + }; + /** @description Acknowledgement that a retry of a campaign's failed sends began. */ + CampaignV1RetryFailed: { + /** Format: uuid */ + id: string; + /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ + queued: number; + }; + /** @description Cursor-paginated list of segments. */ + SegmentV1List: { + data: components["schemas"]["SegmentV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A segment as exposed on the v1 API. */ + SegmentV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + /** @enum {string} */ + type: "DYNAMIC" | "STATIC"; + condition: components["schemas"]["FilterConditionV1"] | null; + track_membership: boolean; + member_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ + SegmentV1Create: { + name: string; + description?: string; + /** + * @default DYNAMIC + * @enum {string} + */ + type: "DYNAMIC" | "STATIC"; + condition?: components["schemas"]["FilterConditionV1"]; + /** + * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. + * @default false + */ + track_membership: boolean; + }; + /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ + SegmentV1Update: { + name?: string; + description?: string; + condition?: components["schemas"]["FilterConditionV1"]; + track_membership?: boolean; + }; + /** @description Acknowledgement that a segment was deleted. */ + SegmentV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Cursor-paginated list of the contacts belonging to a segment. */ + SegmentContactV1List: { + data: components["schemas"]["SegmentContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact belonging to a segment. */ + SegmentContactV1: { + /** Format: uuid */ + id: string; + email: string; + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: { + [key: string]: unknown; + }; + /** Format: date-time */ + created_at: string; + }; + /** @description Cursor-paginated list of workflows. */ + WorkflowV1List: { + data: components["schemas"]["WorkflowV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description An automation workflow as exposed on the v1 API. */ + WorkflowV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + enabled: boolean; + /** @enum {string} */ + trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ + event_name: string | null; + allow_reentry: boolean; + max_executions_per_hour: number | null; + /** @description Incremented on every structural (step/transition) change. */ + version: number; + /** @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. */ + step_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/workflows. */ + WorkflowCreateV1: { + name: string; + description?: string; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; + /** @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. */ + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ + enabled?: boolean; + allow_reentry?: boolean; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + }; + /** + * @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 {string} + */ + WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description One step of a linear workflow sequence. */ + WorkflowSequenceStepV1: { + type: components["schemas"]["WorkflowSequenceStepTypeV1"]; + /** @description Human-readable label, e.g. `Day 0: welcome`. */ + name: string; + /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @description For `SEND_EMAIL`: a template in this project. + */ + template_id?: string; + }; + /** + * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @enum {string} + */ + WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @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. */ + WorkflowUpdateV1: { + name?: string; + description?: string; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + enabled?: boolean; + allow_reentry?: boolean; + /** @description Per-workflow start rate cap. `null` removes the cap. */ + max_executions_per_hour?: number | null; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + }; + /** @description Confirmation that a workflow was deleted. */ + WorkflowDeletedV1: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Cursor-paginated list of workflow executions, newest first. */ + WorkflowExecutionV1List: { + data: components["schemas"]["WorkflowExecutionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One contact's run through a workflow. */ + WorkflowExecutionV1: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + workflow_id: string; + /** Format: uuid */ + contact_id: string; + /** @enum {string} */ + status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + /** Format: uuid */ + current_step_id: string | null; + exit_reason: string | null; + /** Format: date-time */ + started_at: string; + /** Format: date-time */ + completed_at: string | null; + }; + /** @description Body for POST /api/v1/workflows/{id}/executions. */ + WorkflowExecutionStartV1: { + /** + * Format: uuid + * @description Contact to enter the workflow. Must belong to this project. + */ + contact_id: string; + /** @description Extra variables merged into the contact's data for this run. */ + context?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Execution, email and conversion totals for one workflow. */ + WorkflowStatsV1: { + /** Format: uuid */ + workflow_id: string; + name: string; + enabled: boolean; + trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + /** @description Steps in the workflow's graph, trigger step included. */ + step_count: number; + total: number; + /** @description Execution counts keyed by status; a status with no executions is absent. */ + by_status: { + [key: string]: number; + }; + /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ + completion_rate: number | null; + avg_duration_ms: number | null; + emails: { + sent: number; + opened: number; + clicked: number; + }; + conversions: { + /** Format: uuid */ + goal_id: string; + name: string; + event_name: string; + count: number; + }[]; + }; + /** @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. */ + WorkflowGraphV1: { + /** Format: uuid */ + workflow_id: string; + /** @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. */ + version: number; + steps: components["schemas"]["WorkflowStepReadV1"][]; + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @description One node of a workflow graph, as read. */ + WorkflowStepReadV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** @enum {string} */ + type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Where this step sits on the editor canvas. */ + WorkflowStepPositionV1: { + x: number; + y: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description One directed edge between two steps. */ + WorkflowTransitionV1: { + /** + * Format: uuid + * @description Caller-chosen on a write, exactly like a step id. + */ + id: string; + /** Format: uuid */ + from_step_id: string; + /** Format: uuid */ + to_step_id: string; + /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ + condition: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** @description Evaluation order among the edges leaving one step; lowest first. */ + priority: number; + }; + /** @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. */ + WorkflowGraphReplaceV1: { + /** @description The complete step set. Exactly one must be a `TRIGGER`. */ + steps: components["schemas"]["WorkflowStepV1"][]; + /** @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. */ + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @description One node of a workflow graph. */ + WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; + /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ + WorkflowTriggerStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "TRIGGER"; + config: { + eventName?: string; + intervalMs?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ + WorkflowSendEmailStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_EMAIL"; + config: { + /** Format: uuid */ + templateId?: string; + subject?: string; + body?: string; + recipient?: { + /** @enum {string} */ + type: "CONTACT" | "CUSTOM"; + /** Format: email */ + customEmail?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ + WorkflowDelayStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "DELAY"; + config: { + amount?: number; + /** @enum {string} */ + unit?: "minutes" | "hours" | "days"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ + WorkflowWaitForEventStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WAIT_FOR_EVENT"; + config: { + eventName?: string; + timeout?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @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. */ + WorkflowConditionStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "CONDITION"; + config: { + /** @enum {string} */ + mode?: "multi"; + field?: string; + /** @enum {string} */ + operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + branches?: ({ + id: string; + name: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + })[]; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Ends the run early and stamps `exit_reason`. */ + WorkflowExitStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "EXIT"; + config: { + reason?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @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. */ + WorkflowWebhookStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WEBHOOK"; + config: { + /** Format: uri */ + url?: string; + /** @enum {string} */ + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + body?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ + WorkflowUpdateContactStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "UPDATE_CONTACT"; + config: { + updates?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + subscribed?: boolean; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ + WorkflowSendAtOptimalTimeStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_AT_OPTIMAL_TIME"; + config: { + /** Format: uuid */ + templateId?: string; + fallbackHour?: number; + maxDelayHours?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ + WorkflowCloneV1: { + /** @description Name for the copy. Defaults to `Copy of `. */ + name?: string; + }; + /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ + WorkflowStateChangeV1: { + workflow: components["schemas"]["WorkflowV1"]; + /** @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). */ + cancelled_executions: number; + }; + /** @description Receipt for a single transactional send. */ + EmailV1: { + /** + * Format: uuid + * @description The Email row this send created. Quote it in support requests. + */ + id: string; + /** + * @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 {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; + /** + * Format: email + * @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. + */ + from: string; + }; + /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ + SendEmailV1: { + subject?: string; + body?: string; + /** Format: uuid */ + template?: string; + subscribed?: boolean; + name?: string; + from?: string | { + name?: string; + /** Format: email */ + email: string; + }; + /** Format: email */ + reply?: string; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + attachments?: { + filename: string; + content: string; + contentType: string; + contentId?: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + }[]; + tags?: string[]; + cc?: string[]; + bcc?: string[]; + /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ + to: string | { + name?: string; + /** Format: email */ + email: string; + }; + }; + /** @description Receipt for a sandbox test send. */ + EmailTestV1: { + /** + * Format: uuid + * @description The Email row this send created. + */ + id: string; + /** + * @description Delivery status at the moment of the response — `PENDING` for a send still queued. + * @enum {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; + /** + * Format: email + * @description This project's sandbox sender — resolved server-side, never from the body. + */ + from: string; + /** + * @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 {boolean} + */ + sandbox: true; + }; + /** @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. */ + SendTestEmailV1: { + /** + * Format: email + * @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. + */ + to?: string; + subject: string; + /** @description HTML body. Merge tags are rendered as on any other send. */ + body: string; + /** @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. */ + from?: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactV1List: { + data: components["schemas"]["ContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact as exposed on the v1 API. */ + ContactV1: { + /** Format: uuid */ + id: string; + email: string; + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/contacts. */ + ContactV1Create: { + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @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. */ + ContactV1Update: { + subscribed?: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description Acknowledgement that a contact was deleted. */ + ContactV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/lists/{id}/subscribe. */ + ListSubscribe: { + /** Format: email */ + email: string; + /** @description Custom fields to upsert onto the contact as part of subscribing. */ + data?: { + [key: string]: unknown; + }; + /** + * @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. + * @default false + */ + allowResubscribe: boolean; + }; + /** @description Body for POST /api/lists/{id}/unsubscribe. */ + ListUnsubscribe: { + /** Format: email */ + email: string; + }; + /** @description Cursor-paginated list of subscriber lists. */ + ListV1List: { + data: components["schemas"]["ListV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A subscriber list as exposed on the v1 API. */ + ListV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + double_opt_in: boolean; + /** Format: uuid */ + confirmation_template_id: string | null; + redirect_url: string | null; + /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ + member_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/lists. */ + ListV1Create: { + name: string; + description?: string | null; + /** + * @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. + * @default false + */ + double_opt_in: boolean; + /** Format: uuid */ + confirmation_template_id?: string | null; + /** + * Format: uri + * @description Where a confirmed contact is sent after following the confirmation link. + */ + redirect_url?: string | null; + }; + /** @description Body for PATCH /api/v1/lists/{id}. */ + ListV1Update: { + name?: string; + description?: string | null; + double_opt_in?: boolean; + /** Format: uuid */ + confirmation_template_id?: string | null; + /** Format: uri */ + redirect_url?: string | null; + }; + /** @description Acknowledgement that a list was deleted. */ + ListV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @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. */ + AddDomainBody: { + /** Format: uuid */ + projectId?: string; + domain: string; + /** + * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. + * @enum {string} + */ + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"]; + /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ + streamDefault?: boolean; + }; + /** + * @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 {string} + */ + SendingStream: "TRANSACTIONAL" | "MARKETING"; + /** @description Body for PATCH /api/domains/{id}. */ + AssignDomainStream: { + /** + * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. + * @enum {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @description Make this the project's default identity for its stream, demoting whichever held it. */ + streamDefault?: boolean; + /** + * Format: email + * @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. + */ + defaultFromAddress?: string | null; + }; + /** @description Cursor-paginated list of sending domains. */ + DomainV1List: { + data: components["schemas"]["DomainV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A sending domain as exposed on the v1 API. */ + DomainV1: { + /** Format: uuid */ + id: string; + domain: string; + verified: boolean; + region: string | null; + stream: components["schemas"]["SendingStream"] & (string | null); + stream_default: boolean; + default_from_address: string | null; + mail_from_domain: string | null; + /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ + mail_from_domain_status: string | null; + dkim_verified: boolean; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/domains. */ + DomainV1Create: { + domain: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"] & unknown; + /** @description Make this the project's default identity for `stream`. Requires `stream`. */ + stream_default?: boolean; + }; + /** @description Acknowledgement that a sending domain was removed. */ + DomainV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/templates. */ + CreateTemplate: { + name: string; + description?: string; + subject: string; + body: string; + /** Format: email */ + from: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Body for PATCH /api/templates/{id}. */ + UpdateTemplate: { + name?: string; + description?: string; + subject?: string; + body?: string; + /** Format: email */ + from?: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** @enum {string} */ + emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Cursor-paginated list of templates. */ + TemplateV1List: { + data: components["schemas"]["TemplateV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description An email template as exposed on the v1 API. */ + TemplateV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + subject: string; + body: string; + from: string; + from_name: string | null; + reply_to: string | null; + /** @enum {string} */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/templates. */ + TemplateV1Create: { + name: string; + description?: string | null; + subject: string; + body: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Body for PATCH /api/v1/templates/{id}. */ + TemplateV1Update: { + name?: string; + description?: string | null; + subject?: string; + body?: string; + /** Format: email */ + from?: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Acknowledgement that a template was deleted. */ + TemplateV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/snippets. */ + CreateSnippet: { + name: string; + description?: string | null; + body: string; + }; + /** @description Body for PATCH /api/snippets/{id}. */ + UpdateSnippet: { + name?: string; + description?: string | null; + body?: string; + }; + /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ + CreateWebhook: { + /** Format: uri */ + url: string; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + }; + /** @description Body for PATCH /api/webhooks/{id}. */ + UpdateWebhook: { + /** Format: uri */ + url?: string; + eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + }; + /** @description Cursor-paginated list of webhook endpoints. */ + WebhookV1List: { + data: components["schemas"]["WebhookV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @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. */ + WebhookV1: { + /** Format: uuid */ + id: string; + url: string; + event_types: string[]; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description A newly created webhook and its one-time signing secret. */ + WebhookV1Created: { + webhook: components["schemas"]["WebhookV1"]; + /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + }; + /** @description Body for POST /api/v1/webhooks. */ + WebhookV1Create: { + /** Format: uri */ + url: string; + event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + }; + /** @description Body for PATCH /api/v1/webhooks/{id}. */ + WebhookV1Update: { + /** Format: uri */ + url?: string; + event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + }; + /** @description Acknowledgement that a webhook was deleted. */ + WebhookV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ + WebhookV1SecretRotated: { + /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + /** + * Format: date-time + * @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. + */ + previous_secret_expires_at: string; + }; + CreateApiKeyBody: { + name: string; + /** @enum {string} */ + legacyGrantPreset?: "FULL" | "SENDING_ONLY"; + /** + * @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 {string} + */ + mode?: "LIVE" | "TEST"; + /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ + scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + /** Format: uuid */ + domainId?: string | null; + }; + /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ + AddSuppression: { + /** Format: email */ + email: string; + /** + * @default MANUAL + * @enum {string} + */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; + /** @description Cursor-paginated list of suppressed addresses. */ + SuppressionV1List: { + data: components["schemas"]["SuppressionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A suppressed address as exposed on the v1 API. */ + SuppressionV1: { + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + source: string; + /** Format: date-time */ + created_at: string; + }; + /** @description Body for POST /api/v1/suppressions. */ + SuppressionV1Create: { + /** Format: email */ + email: string; + /** + * @default MANUAL + * @enum {string} + */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; + /** @description Acknowledgement that an address was un-suppressed. */ + SuppressionV1Deleted: { + email: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/track — record a custom event for a contact. */ + TrackEvent: { + event: string; + /** Format: email */ + email: string; + subscribed?: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + }; + /** @description Cursor-paginated list of events, newest first. */ + EventV1List: { + data: components["schemas"]["EventV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A recorded custom event. */ + EventV1: { + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + contact_id: string | null; + /** Format: uuid */ + email_id: string | null; + /** @description The payload recorded with the event, or null. */ + payload: { + [key: string]: unknown; + } | null; + /** Format: date-time */ + created_at: string; + }; + /** @description Body for POST /api/v1/events. */ + EventTrackV1: { + /** @description Event name, e.g. `user.signup`. */ + name: string; + /** + * Format: uuid + * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. + */ + contact_id?: string; + /** @description Arbitrary event payload. */ + payload?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Every distinct event name in the project, most frequent first. */ + EventNamesV1: { + data: string[]; + }; + /** @description Per-name event counts over the applied window. */ + EventStatsV1: { + data: { + name: string; + count: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; /** @description The time range this response was computed over, after the 90-day clamp. */ AnalyticsWindowV1: { /** Format: date-time */ - from: string; + from: string; + /** Format: date-time */ + to: string; + }; + /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ + AnalyticsTimeseriesV1: { + data: { + /** Format: date-time */ + date: string; + emails: number; + delivered: number; + opens: number; + clicks: number; + bounces: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Campaign counters and engagement over the window. */ + AnalyticsCampaignStatsV1: { + total: number; + /** @description Campaigns in DRAFT or SCHEDULED. */ + active: number; + completed: number; + /** @description Percentage, one decimal place. */ + average_open_rate: number; + average_click_rate: number; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Sent campaigns ranked by open rate. */ + AnalyticsTopCampaignsV1: { + data: { + /** Format: uuid */ + id: string; + subject: string; + sent: number; + opened: number; + clicked: number; + open_rate: number; + click_rate: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @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. */ + DeliverabilityDiagnosisV1: { + domain: string; + address: string | null; + /** Format: date-time */ + checked_at: string; + identity: components["schemas"]["DeliverabilityIdentityV1"]; + suppression: components["schemas"]["DeliverabilitySuppressionV1"]; + recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; + /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ + findings: components["schemas"]["DeliverabilityFindingV1"][]; + }; + /** @description The sending identity's DNS health, as last refreshed. */ + DeliverabilityIdentityV1: { + /** @description Whether this project has a domain record at all. False makes every other field null. */ + registered: boolean; + verified: boolean; + /** + * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. + * @enum {string|null} + */ + dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description SPF alignment for the sending identity. + * @enum {string|null} + */ + spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description The DMARC policy published at `_dmarc.`. + * @enum {string|null} + */ + dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description Inbound receiving only. Null unless the domain has receiving enabled. + * @enum {string|null} + */ + mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + mail_from_domain: string | null; + /** @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. */ + mail_from_domain_status: string | null; + /** + * Format: date-time + * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. + */ + last_checked_at: string | null; + }; + /** @description Null unless the request named an `address`. */ + DeliverabilitySuppressionV1: { + suppressed: boolean; + /** @enum {string|null} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; + /** @enum {string|null} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; + /** Format: date-time */ + suppressed_at: string | null; + } | null; + /** @description Delivery outcomes over the requested window. */ + DeliverabilityRecentDeliveryV1: { + window_days: number; + /** + * @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 {string} + */ + scope: "project"; + sent: number; + delivered: number; + bounced: number; + complained: number; + failed: number; + /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ + bounce_rate: number | null; + complaint_rate: number | null; + }; + /** @description One diagnosed problem, with its fix. */ + DeliverabilityFindingV1: { + /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ + code: string; + severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; + /** @description What is wrong, in one sentence. */ + summary: string; + /** @description What to do about it. */ + remedy: string; + }; + /** + * @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 {string} + */ + DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; + /** @description Cursor-paginated recipient-domain rollup, newest day first. */ + RecipientDomainStatsV1List: { + data: components["schemas"]["RecipientDomainStatsV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Delivery outcomes for one recipient domain on one day. */ + RecipientDomainStatsV1: { + /** @description The recipient's domain, lowercased: the part after the `@`. */ + domain: string; + /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ + day: string; + sent: number; + delivered: number; + bounced: number; + complained: number; + opened: number; + /** + * Format: date-time + * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. + */ + computed_at: string; + }; + /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ + DmarcReportV1List: { + data: components["schemas"]["DmarcReportV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One DMARC aggregate (RUA) report. */ + DmarcReportV1: { + id: string; + /** @description The receiver's own id for this report. */ + report_id: string; + /** @description The reporting receiver, e.g. `google.com`. */ + org_name: string; + /** @description The domain of yours the report is about. */ + policy_domain: string; + /** Format: date-time */ + range_begin: string; + /** Format: date-time */ + range_end: string; + total_count: number; + /** @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. */ + pass_count: number; + fail_count: number; + /** @description Per-sending-source rows, as the receiver reported them. */ + sources: { + source_ip: string; + count: number; + disposition: string; + dkim: string; + spf: string; + header_from: string; + }[]; + /** Format: date-time */ + received_at: string; + }; + /** @description Current email usage against the limits that are actually enforced. */ + UsageV1: { + /** + * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. + * @enum {string} + */ + plan: "free" | "pro" | "custom"; + monthly: { + emails_sent: number; + /** @description Monthly cap on the total. Null when per-category limits govern instead. */ + limit: number | null; + categories: { + transactional: { + emails_sent: number; + limit: number | null; + }; + campaign: { + emails_sent: number; + limit: number | null; + }; + workflow: { + emails_sent: number; + limit: number | null; + }; + inbound: { + emails_sent: number; + limit: number | null; + }; + }; + }; + daily: { + /** @description Today's sends. Null when the counter could not be read. */ + emails_sent: number | null; + limit: number; + /** @enum {string} */ + trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + }; + }; + /** @description The project the presented credential is scoped to. */ + ProjectV1: { + /** Format: uuid */ + id: string; + name: string; + /** @description A disabled project sends nothing; every send is refused. */ + disabled: boolean; + /** @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. */ + sandbox_address: string | null; + /** @description Locked once the first domain is added. */ + ses_region: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; + /** Format: date-time */ + created_at: string; + }; + /** @description A receiving mailbox on one of the project's verified domains. */ + Mailbox: { + /** Format: uuid */ + id: string; + /** + * Format: email + * @description The full mailbox address, e.g. `support@superbooks.io`. + */ + address: string; + displayName: string | null; + /** + * @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 {string} + */ + status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; + /** @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. */ + quotaBytes: number | null; + /** + * Format: uuid + * @description The verified domain this mailbox lives on. + */ + domainId: string; + /** Format: date-time */ + createdAt: string; + }; + /** @description A mailbox plus its IMAP/SMTP connection settings. */ + MailboxDetail: components["schemas"]["Mailbox"] & { + /** @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. */ + settings: { + imap: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + smtp: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + }; + }; + CreateMailboxBody: { + /** + * Format: uuid + * @description Defaults to the project the credential resolves to. Naming a different one is refused. + */ + projectId?: string; + /** + * Format: uuid + * @description A VERIFIED domain belonging to this project. + */ + domainId: string; + /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ + localPart: string; + displayName?: string; + /** @description NOT IMPLEMENTED — sending any value answers 400. */ + quotaBytes?: number; + }; + /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ + DraftMailboxMessage: { + /** @enum {string} */ + mode: "draft" | "rewrite" | "subject"; + brief?: string; + draft?: string; + instruction?: string; + /** @enum {string} */ + tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; + recipientContext?: string; + senderAddress?: string; + }; + /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ + ComposeMailboxMessage: { + to: string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + body: string; + }; + /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ + AppPassword: { + /** Format: uuid */ + id: string; + /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ + name: string; + /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ + scopes: ("imap" | "smtp")[]; + /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ + lastFour: string; + /** + * Format: date-time + * @description Null until a mail client has authenticated with it at least once. + */ + lastUsedAt: string | null; /** Format: date-time */ - to: string; + createdAt: string; + }; + /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ + AppPasswordReveal: { + /** Format: uuid */ + id: string; + /** + * Format: uri + * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the link stops working. Five minutes after creation; the password itself does not expire. + */ + revealExpiresAt: string; + }; + /** @description Body for POST /api/mailboxes/:id/app-passwords. */ + CreateAppPassword: { + name: string; + /** + * @default [ + * "imap", + * "smtp" + * ] + */ + scopes: ("imap" | "smtp")[]; + }; + /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ + VerifyEmail: { + /** Format: email */ + email: string; + }; + TopicListV1: { + data: components["schemas"]["TopicV1"][]; + cursor: string | null; + has_more: boolean; + }; + /** @description One subject this project mails about. */ + TopicV1: { + id: string; + /** @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. */ + key: string; + name: string; + description: string | null; + /** @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`. */ + default_opt_in: boolean; + /** @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. */ + archived: boolean; + /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ + subscribed_count: number; + unsubscribed_count: number; + /** Format: date-time */ + created_at: string; + }; + TopicCreateV1: { + /** @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. */ + key: string; + name: string; + description?: string | null; + default_opt_in?: boolean; + }; + /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ + TopicUpdateV1: { + name?: string; + description?: string | null; + default_opt_in?: boolean; + archived?: boolean; + }; + TopicSubscriptionV1: { + topic_id: string; + contact_id: string; + status: components["schemas"]["TopicSubscriptionStatusV1"]; + /** Format: date-time */ + confirmed_at: string | null; + /** @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. */ + confirmation_url: string | null; + }; + /** + * @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 {string} + */ + TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; + TopicSubscribeV1: { + /** Format: uuid */ + contact_id: string; + /** @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. */ + subscribed: boolean; + }; + /** @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. */ + ContactTopicPreferencesV1: { + contact_id: string; + /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ + subscribed: boolean; + topics: { + topic_id: string; + /** @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. */ + key: string; + name: string; + /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ + subscribed: boolean; + pending: boolean; + }[]; + }; + /** @description One verdict per address, in the order they were given. */ + EmailValidationBatchV1: { + results: components["schemas"]["EmailValidationV1"][]; + }; + /** @description One address's verdict, with the evidence behind it. */ + EmailValidationV1: { + email: string; + verdict: components["schemas"]["EmailValidationVerdictV1"]; + /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ + is_disposable: boolean; + /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ + is_role_address: boolean; + /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ + is_personal: boolean; + /** @description The domain publishes MX records. */ + has_mx_records: boolean; + /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ + reasons: string[]; + }; + /** + * @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 {string} + */ + EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; + EmailValidationBatchRequestV1: { + /** @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. */ + emails: string[]; + }; + /** @description One bulk validation run over a list. */ + EmailValidationRunV1: { + id: string; + list_id: string | null; + /** @enum {string} */ + status: "pending" | "running" | "completed" | "failed"; + /** @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. */ + processed_count: number; + deliverable_count: number; + undeliverable_count: number; + risky_count: number; + /** Format: date-time */ + started_at: string | null; + /** Format: date-time */ + completed_at: string | null; + /** @description Set only on `failed`. Prose for an operator; never parse it. */ + failure_reason: string | null; + /** Format: date-time */ + created_at: string; + }; + /** @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. */ + EmailValidationResultListV1: { + data: (components["schemas"]["EmailValidationV1"] & { + contact_id: string | null; + })[]; + /** @description Pass as `cursor` for the next page; null on the last. */ + cursor: string | null; + has_more: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +interface operations { + v1ListCampaigns: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateCampaign: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Create"]; + }; + }; + responses: { + /** @description Campaign created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1Deleted"]; + }; + }; + /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Update"]; + }; + }; + responses: { + /** @description The updated campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendCampaign: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CampaignV1Send"]; + }; + }; + responses: { + /** @description The campaign, now `SENDING` or `SCHEDULED` */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CancelCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The cancelled campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1PauseCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The paused campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only a `SENDING` campaign can be paused. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ResumeCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The resumed campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetCampaignStats: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1Stats"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListCampaignFailures: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Failed sends */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1FailureList"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1RetryCampaignFailures: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The retry was queued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1RetryFailed"]; + }; + }; + /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a retry is already running for this campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListSegments: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateSegment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Create"]; + }; + }; + responses: { + /** @description Segment created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The segment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1Deleted"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the segment is still used by one or more active campaigns. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Update"]; + }; + }; + responses: { + /** @description The updated segment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListSegmentContacts: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment member list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentContactV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListWorkflows: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateWorkflow: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowCreateV1"]; + }; + }; + responses: { + /** @description Workflow created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowDeletedV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the workflow still has running executions. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowUpdateV1"]; + }; + }; + responses: { + /** @description Updated workflow */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the trigger cannot be changed while executions are running. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListWorkflowExecutions: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only executions in this state. */ + status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + }; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Execution list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1StartWorkflowExecution: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowExecutionStartV1"]; + }; + }; + responses: { + /** @description Execution started */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CancelWorkflowExecution: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow execution id. */ + execution_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancelled execution */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no execution with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflowStats: { + parameters: { + query?: { + /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ + from?: string | null; + }; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStatsV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflowGraph: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow's graph */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowGraphV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ReplaceWorkflowGraph: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; + }; + }; + responses: { + /** @description The graph as it now stands */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowGraphV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CloneWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["WorkflowCloneV1"]; + }; + }; + responses: { + /** @description The cloned workflow */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1PauseWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow, and the number of runs this call cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStateChangeV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ResumeWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow, with `cancelled_executions` always 0 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStateChangeV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmailV1"]; + }; + }; + responses: { + /** @description Email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendTestEmail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendTestEmailV1"]; + }; + }; + responses: { + /** @description Test email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailTestV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + listEmails: { + parameters: { + query?: { + limit?: number; + cursor?: string; + tag?: string; + /** @description Delivery lifecycle of the message. Engagement is reported separately. */ + status?: components["schemas"]["EmailDeliveryStatus"]; + from?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Email list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailListResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + sendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmail"]; + }; + }; + responses: { + /** @description Email accepted / sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendEmailResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @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. */ - ApiKey: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** Format: uuid */ - domainId: string | null; - /** Format: uuid */ - id: string; - /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ - lastFour: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - lastUsedAt: string | null; - name: string; - /** @enum {string} */ - permission: "FULL" | "SENDING_ONLY"; - /** Format: uuid */ - projectId: string; - /** - * Format: date-time - * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. - */ - revokedAt: string | null; - /** @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. */ - scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test")[]; + }; + getEmail: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ - ApiKeyListResponse: { - data: components["schemas"]["ApiKey"][]; - /** @enum {boolean} */ - success: true; + requestBody?: never; + responses: { + /** @description Email and its delivery history */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailDetailResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ - AppPassword: { - /** Format: date-time */ - createdAt: string; - /** Format: uuid */ - id: string; - /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ - lastFour: string; - /** - * Format: date-time - * @description Null until a mail client has authenticated with it at least once. - */ - lastUsedAt: string | null; - /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ - name: string; - /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ - scopes: ("imap" | "smtp")[]; + }; + sendEmailBatch: { + parameters: { + query?: never; + header?: { + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; }; - /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ - AppPasswordReveal: { - /** Format: uuid */ - id: string; - /** - * Format: date-time - * @description When the link stops working. Five minutes after creation; the password itself does not expire. - */ - revealExpiresAt: string; - /** - * Format: uri - * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. - */ - revealUrl: string; + requestBody: { + content: { + "application/json": components["schemas"]["BatchSendBody"]; + }; }; - /** @description Per-row result in a batch send response. */ - BatchEntryResult: { - data?: components["schemas"]["SendEmailData"]; - error?: { - code: string; - message: string; + responses: { + /** @description All entries sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Partial success — at least one entry failed */ + 207: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - index: number; - /** @enum {string} */ - status: "ok" | "error"; - }; - /** @description Batch send wrapper. Up to 100 entries. */ - BatchSendBody: { - emails: components["schemas"]["SendEmail"][]; - }; - /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ - BatchSendResponse: { - data: components["schemas"]["BatchEntryResult"][]; - success: boolean; - }; - /** @description A campaign as exposed on the v1 API. */ - CampaignV1: { - /** @enum {string} */ - audience_type: "ALL" | "FILTERED" | "SEGMENT"; - /** Format: date-time */ - created_at: string; - /** Format: uuid */ - id: string; - name: string; - /** Format: date-time */ - scheduled_at: string | null; - /** Format: date-time */ - sent_at: string | null; - stats: { - bounced: number; - clicked: number; - delivered: number; - opened: number; - sent: number; - total_recipients: number; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {string} */ - status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; - subject: string; - }; - /** @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`. */ - CampaignV1Create: { - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** - * @description `ALL` — every subscribed contact. `FILTERED` — the contacts matching `audience_condition`. `SEGMENT` — the members of `segment_id`. - * @enum {string} - */ - audience_type: "ALL" | "FILTERED" | "SEGMENT"; - body: string; - description?: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from: string; - from_name?: string | null; - name: string; - /** Format: email */ - reply_to?: string | null; - /** Format: uuid */ - segment_id?: string; - subject: string; - /** - * @default MARKETING - * @enum {string} - */ - type: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; - }; - /** @description Acknowledgement that a campaign was deleted. */ - CampaignV1Deleted: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; - }; - /** @description Cursor-paginated list of campaigns. */ - CampaignV1List: { - data: components["schemas"]["CampaignV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; }; - /** @description Body for POST /api/v1/campaigns/{id}/send. */ - CampaignV1Send: { - /** - * Format: date-time - * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. - */ - scheduled_for?: string; + }; + cancelScheduledEmail: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Materialized delivery and engagement counters for one campaign. */ - CampaignV1Stats: { - bounce_rate: number; - bounced: number; - click_rate: number; - clicked: number; - delivered: number; - delivery_rate: number; - open_rate: number; - opened: number; - sent: number; - total_recipients: number; + requestBody?: never; + responses: { + /** @description Email cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Email already past PENDING */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ - CampaignV1Update: { - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** @enum {string} */ - audience_type?: "ALL" | "FILTERED" | "SEGMENT"; - body?: string; - description?: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from?: string; - from_name?: string | null; - name?: string; - /** Format: email */ - reply_to?: string | null; - /** Format: uuid */ - segment_id?: string; - subject?: string; - /** @enum {string} */ - type?: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + }; + listContacts: { + parameters: { + query?: { + limit?: number; + cursor?: string; + search?: string; + subscribed?: "true" | "false"; + }; + header?: never; + path?: never; + cookie?: never; }; - /** @description A subscriber/contact within a project. */ - Contact: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - customFields?: { - [key: string]: unknown; - } | null; - /** Format: email */ - email: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - subscribed: boolean; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + requestBody?: never; + responses: { + /** @description Contact list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactListResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Bulk create up to 1000 contacts. */ - ContactBulkCreateBody: { - contacts: components["schemas"]["CreateContact"][]; + }; + createContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ - ContactBulkDeleteBody: { - emails?: string[]; - ids?: string[]; + requestBody: { + content: { + "application/json": components["schemas"]["CreateContact"]; + }; }; - /** @description Cursor-paginated list of contacts. */ - ContactListResponse: { - data: { - data: components["schemas"]["Contact"][]; - hasMore: boolean; - /** @description Cursor for the next page, or null on the last page. */ - nextCursor: string | null; - total: number; + responses: { + /** @description Contact created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Email already exists for this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; }; - CreateApiKeyBody: { - /** Format: uuid */ - domainId?: string | null; - name: string; - /** @enum {string} */ - permission?: "FULL" | "SENDING_ONLY"; - /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `permission`. A `SENDING_ONLY` key may carry only `emails:send`. */ - scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test")[]; + }; + upsertContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/mailboxes/:id/app-passwords. */ - CreateAppPassword: { - name: string; - /** - * @default [ - * "imap", - * "smtp" - * ] - */ - scopes: ("imap" | "smtp")[]; + requestBody: { + content: { + "application/json": components["schemas"]["CreateContact"]; + }; }; - /** @description Body for POST /api/contacts and /api/contacts/upsert. */ - CreateContact: { - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - customFields?: { - [key: string]: unknown; + responses: { + /** @description Contact created or updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; }; - CreateMailboxBody: { - displayName?: string; - /** - * Format: uuid - * @description A VERIFIED domain belonging to this project. - */ - domainId: string; - /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ - localPart: string; - /** - * Format: uuid - * @description Defaults to the project the credential resolves to. Naming a different one is refused. - */ - projectId?: string; - /** @description NOT IMPLEMENTED — sending any value answers 400. */ - quotaBytes?: number; + }; + bulkCreateContacts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/templates. */ - CreateTemplate: { - body: string; - description?: string; - /** Format: email */ - from: string; - fromName?: string | null; - name: string; - /** Format: email */ - replyTo?: string | null; - subject: string; - /** - * @default MARKETING - * @enum {string} - */ - type: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkCreateBody"]; + }; }; - /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ - CreateWebhook: { - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** Format: uri */ - url: string; + responses: { + /** @description Bulk-create result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + created: number; + skipped: number; + errors: { + index: number; + message: string; + }[]; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description A sending domain registered with SES. */ - Domain: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - dkim?: { - name: string; - type: string; - value: string; - }[]; - /** Format: uuid */ - id: string; - /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - name: string; - /** Format: uuid */ - projectId: string; - region?: string | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - verified: boolean; + }; + bulkDeleteContacts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description List of all domains for the auth'd project. */ - DomainListResponse: { - data: components["schemas"]["Domain"][]; - /** @enum {boolean} */ - success: true; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkDeleteBody"]; + }; }; - /** @description Outcome of a verification check against SES. */ - DomainVerificationStatus: { - dkim?: { - name: string; - type: string; - value: string; - }[]; - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - mxRecords?: string[]; - verified: boolean; + responses: { + /** @description Bulk-delete result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + deleted: number; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description A sent (or queued) transactional email. */ - Email: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - error?: string | null; - from: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - status: "PENDING" | "SENT" | "DELIVERED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED"; - subject: string; - tags: string[]; - to: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + }; + getContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Single email with its events. */ - EmailGetResponse: { - data: components["schemas"]["Email"]; - /** @enum {boolean} */ - success: true; + requestBody?: never; + responses: { + /** @description Contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Cursor-paginated list of emails. */ - EmailListResponse: { - data: components["schemas"]["Email"][]; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; + }; + deleteContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Receipt for a sandbox test send. */ - EmailTestV1: { - /** - * Format: email - * @description This project's sandbox sender — resolved server-side, never from the body. - */ - from: string; - /** - * Format: uuid - * @description The Email row this send created. - */ - id: string; - /** - * @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 {boolean} - */ - sandbox: true; - /** - * @description Delivery status at the moment of the response — `PENDING` for a send still queued. - * @enum {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; + requestBody?: never; + responses: { + /** @description Contact deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IdResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Receipt for a single transactional send. */ - EmailV1: { - /** - * Format: email - * @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. - */ - from: string; - /** - * Format: uuid - * @description The Email row this send created. Quote it in support requests. - */ - id: string; - /** - * @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 {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; + }; + updateContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; }; - /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ - Error: { - error: { - code: string; - details?: { - errors: unknown[]; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateContactBody"]; + }; + }; + responses: { + /** @description Updated contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; }; - message: string; }; - /** @enum {boolean} */ - success?: false; - }; - /** @description Every distinct event name in the project, most frequent first. */ - EventNamesV1: { - data: string[]; - }; - /** @description Per-name event counts over the applied window. */ - EventStatsV1: { - data: { - count: number; - name: string; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; - }; - /** @description Body for POST /api/v1/events. */ - EventTrackV1: { - /** - * Format: uuid - * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. - */ - contact_id?: string; - /** @description Arbitrary event payload. */ - data?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @description Event name, e.g. `user.signup`. */ - name: string; }; - /** @description A recorded custom event. */ - EventV1: { - /** Format: uuid */ - contact_id: string | null; - /** Format: date-time */ - created_at: string; - /** @description The payload recorded with the event, or null. */ - data: { - [key: string]: unknown; - } | null; - /** Format: uuid */ - email_id: string | null; - /** Format: uuid */ - id: string; - name: string; + }; + v1ListContacts: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the email address. */ + search?: string; + /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ + subscribed?: "true" | "false"; + }; + header?: never; + path?: never; + cookie?: never; }; - /** @description Cursor-paginated list of events, newest first. */ - EventV1List: { - data: components["schemas"]["EventV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + requestBody?: never; + responses: { + /** @description Contact list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; - /** @description A filter condition: one or more groups combined with `logic`. */ - FilterConditionV1: { - groups: components["schemas"]["FilterGroupV1"][]; - /** @enum {string} */ - logic: "AND" | "OR"; + }; + v1CreateContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @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. */ - FilterGroupV1: { - conditions?: components["schemas"]["FilterConditionV1"]; - filters: components["schemas"]["SegmentFilterV1"][]; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Create"]; + }; }; - /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ - IdResponse: { - data: { - /** Format: uuid */ - id: string; + responses: { + /** @description The created contact */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a contact with this email already exists in this project. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/lists/{id}/subscribe. */ - ListSubscribe: { - /** - * @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. - * @default false - */ - allowResubscribe: boolean; - /** @description Custom fields to upsert onto the contact as part of subscribing. */ - data?: { - [key: string]: unknown; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** Format: email */ - email: string; }; - /** @description Result of a list-subscribe call. */ - ListSubscribeResponse: { - data: { - /** @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. */ - confirmToken?: string; - /** @description True when the membership row did not exist before this call. */ - created: boolean; - /** Format: uuid */ - membershipId: string; - /** - * @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 {string|null} - */ - previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; - /** @enum {string} */ - status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; + }; + v1GetContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/lists/{id}/unsubscribe. */ - ListUnsubscribe: { - /** Format: email */ - email: string; + cookie?: never; }; - /** @description Echoes the address that was unsubscribed. */ - ListUnsubscribeResponse: { - data: { - /** Format: email */ - email: string; + requestBody?: never; + responses: { + /** @description The contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description A receiving mailbox on one of the project's verified domains. */ - Mailbox: { - /** - * Format: email - * @description The full mailbox address, e.g. `support@superbooks.io`. - */ - address: string; - /** Format: date-time */ - createdAt: string; - displayName: string | null; - /** - * Format: uuid - * @description The verified domain this mailbox lives on. - */ - domainId: string; - /** Format: uuid */ - id: string; - /** @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. */ - quotaBytes: number | null; - /** - * @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 {string} - */ - status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; - }; - /** @description A mailbox plus its IMAP/SMTP connection settings. */ - MailboxDetail: components["schemas"]["Mailbox"] & { - /** @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. */ - settings: { - imap: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; }; - smtp: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ - Problem: { - /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ - code: string; - /** @description Explanation specific to this occurrence. */ - detail?: string; - /** @description Field-level failures. Present on 422 `validation_error` responses. */ - errors?: { - code: string; - message: string; - /** @description RFC 6901 JSON Pointer to the offending field. */ - pointer: string; - }[]; - /** @description Request path the failure occurred on. */ - instance?: string; - /** @description Correlation id — quote it in support requests. */ - request_id?: string; - /** @description HTTP status code, repeated in the body. */ - status: number; - /** @description Short, stable summary — the same for every occurrence of a `type`. */ - title: string; - /** - * Format: uri - * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. - */ - type: string; - }; - ProjectRecord: { - billingLimitCampaigns: number | null; - billingLimitInbound: number | null; - billingLimitTransactional: number | null; - billingLimitWorkflows: number | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - disabled: boolean; - disabledReason: string | null; - /** Format: uuid */ - id: string; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - name: string; - organizationId: string | null; - /** @description Local-part of the sandbox quick-start sender; null until first derived. */ - sandboxHandle: string | null; - sesRegion: string | null; - stripeCustomerId: string | null; - stripeSubscriptionId: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description The project the presented credential is scoped to. */ - ProjectV1: { - /** Format: date-time */ - created_at: string; - /** @description A disabled project sends nothing; every send is refused. */ - disabled: boolean; - /** Format: uuid */ - id: string; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - name: string; - /** @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. */ - sandbox_address: string | null; - /** @description Locked once the first domain is added. */ - ses_region: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - }; - /** @description A contact belonging to a segment. */ - SegmentContactV1: { - /** Format: date-time */ - created_at: string; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: { - [key: string]: unknown; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - email: string; - /** Format: uuid */ - id: string; - subscribed: boolean; - }; - /** @description Cursor-paginated list of the contacts belonging to a segment. */ - SegmentContactV1List: { - data: components["schemas"]["SegmentContactV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @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). */ - SegmentFilterV1: { - field: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; - /** @enum {string} */ - unit?: "days" | "hours" | "minutes"; - value?: unknown; - }; - /** @description A segment as exposed on the v1 API. */ - SegmentV1: { - condition: components["schemas"]["FilterConditionV1"] | null; - /** Format: date-time */ - created_at: string; - description: string | null; - /** Format: uuid */ - id: string; - member_count: number; - name: string; - track_membership: boolean; - /** @enum {string} */ - type: "DYNAMIC" | "STATIC"; - /** Format: date-time */ - updated_at: string; }; - /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ - SegmentV1Create: { - condition?: components["schemas"]["FilterConditionV1"]; - description?: string; - name: string; - /** - * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. - * @default false - */ - track_membership: boolean; - /** - * @default DYNAMIC - * @enum {string} - */ - type: "DYNAMIC" | "STATIC"; + }; + v1DeleteContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description Acknowledgement that a segment was deleted. */ - SegmentV1Deleted: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; + requestBody?: never; + responses: { + /** @description Contact deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1Deleted"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; - /** @description Cursor-paginated list of segments. */ - SegmentV1List: { - data: components["schemas"]["SegmentV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + }; + v1UpdateContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ - SegmentV1Update: { - condition?: components["schemas"]["FilterConditionV1"]; - description?: string; - name?: string; - track_membership?: boolean; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Update"]; + }; }; - /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ - SendEmail: { - attachments?: { - content: string; - contentId?: string; - contentType: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - filename: string; - }[]; - bcc?: string[]; - body?: string; - cc?: string[]; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + responses: { + /** @description The updated contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; }; - from?: string | { - /** Format: email */ - email: string; - name?: string; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - headers?: { - [key: string]: string; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - name?: string; - /** Format: email */ - reply?: string; - subject?: string; - subscribed?: boolean; - tags?: string[]; - /** Format: uuid */ - template?: string; - to: string | { - /** Format: email */ - email: string; - name?: string; - } | (string | { - /** Format: email */ - email: string; - name?: string; - })[]; - }; - /** @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`. */ - SendEmailData: { - emails: components["schemas"]["SendEmailRecipientResult"][]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; }; - /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ - SendEmailRecipientResult: { - contact: { - /** Format: email */ - email: string; - /** Format: uuid */ + }; + subscribeToList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description List id. */ id: string; }; - /** Format: uuid */ - email: string; + cookie?: never; }; - /** @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. */ - SendEmailResponse: { - data: components["schemas"]["SendEmailData"]; - /** @enum {boolean} */ - success: true; + requestBody: { + content: { + "application/json": components["schemas"]["ListSubscribe"]; + }; }; - /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ - SendEmailV1: { - attachments?: { - content: string; - contentId?: string; - contentType: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - filename: string; - }[]; - bcc?: string[]; - body?: string; - cc?: string[]; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + responses: { + /** @description Contact subscribed, or an existing membership returned unchanged */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSubscribeResponse"]; + }; }; - from?: string | { - /** Format: email */ - email: string; - name?: string; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - headers?: { - [key: string]: string; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - name?: string; - /** Format: email */ - reply?: string; - subject?: string; - subscribed?: boolean; - tags?: string[]; - /** Format: uuid */ - template?: string; - /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ - to: string | { - /** Format: email */ - email: string; - name?: string; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - }; - /** @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. */ - SendTestEmailV1: { - /** @description HTML body. Merge tags are rendered as on any other send. */ - body: string; - /** @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. */ - from?: string; - subject: string; - /** - * Format: email - * @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. - */ - to?: string; - }; - /** @description Bare success envelope with no payload. */ - SuccessEmpty: { - /** @enum {boolean} */ - success: true; - }; - /** @description A single suppressed-email record. */ - Suppression: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** Format: email */ - email: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD"; - }; - /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ - SuppressionCheckResponse: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt?: string; - /** @enum {string} */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; - suppressed: boolean; - }; - /** @description Cursor-paginated list of suppressions. */ - SuppressionListResponse: { - cursor?: string | null; - data: components["schemas"]["Suppression"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; - }; - /** @description A reusable email template. */ - Template: { - body: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - description?: string | null; - /** Format: email */ - from: string; - fromName?: string | null; - /** Format: uuid */ - id: string; - name: string; - /** Format: uuid */ - projectId: string; - /** Format: email */ - replyTo?: string | null; - subject: string; - /** @enum {string} */ - type: "MARKETING" | "TRANSACTIONAL" | "HEADLESS"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description Cursor-paginated list of templates. */ - TemplateListResponse: { - data: { - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - data: components["schemas"]["Template"][]; - hasMore: boolean; - total: number; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/track — record a custom event for a contact. */ - TrackEvent: { - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + /** @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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** Format: email */ - email: string; - event: string; - subscribed?: boolean; - }; - /** @description Response from POST /api/track. */ - TrackEventResponse: { - data: { - /** Format: uuid */ - contact: string; - /** Format: uuid */ - event: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; }; - /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ - UpdateContactBody: { - customFields?: { - [key: string]: unknown; + }; + unsubscribeFromList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description List id. */ + id: string; }; - subscribed?: boolean; - }; - /** @description Body for PATCH /api/templates/{id}. */ - UpdateTemplate: { - body?: string; - description?: string; - /** Format: email */ - from?: string; - fromName?: string | null; - name?: string; - /** Format: email */ - replyTo?: string | null; - subject?: string; - /** @enum {string} */ - type?: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + cookie?: never; }; - /** @description Body for PATCH /api/webhooks/{id}. */ - UpdateWebhook: { - eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; - /** Format: uri */ - url?: string; + requestBody: { + content: { + "application/json": components["schemas"]["ListUnsubscribe"]; + }; }; - /** @description Current email usage against the limits that are actually enforced. */ - UsageV1: { - daily: { - /** @description Today's sends. Null when the counter could not be read. */ - emails_sent: number | null; - limit: number; - /** @enum {string} */ - trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + responses: { + /** @description Contact unsubscribed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListUnsubscribeResponse"]; + }; }; - monthly: { - categories: { - campaign: { - emails_sent: number; - limit: number | null; - }; - inbound: { - emails_sent: number; - limit: number | null; - }; - transactional: { - emails_sent: number; - limit: number | null; - }; - workflow: { - emails_sent: number; - limit: number | null; - }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; - emails_sent: number; - /** @description Monthly cap on the total. Null when per-category limits govern instead. */ - limit: number | null; }; - /** - * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. - * @enum {string} - */ - plan: "free" | "pro" | "custom"; - }; - /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ - VerifyEmail: { - /** Format: email */ - email: string; - }; - /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ - VerifyEmailResponse: { - data: { - /** Format: email */ - email: string; - reason?: string; - valid: boolean; - } & { - [key: string]: unknown; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; }; - /** @description A user-managed outbound webhook. */ - Webhook: { - consecutiveFailures: number; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - disabledAt?: string | null; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** Format: uuid */ - id: string; - lastFour?: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - /** Format: uri */ - url: string; + }; + v1ListLists: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; }; - /** @description An attempted webhook delivery. */ - WebhookCall: { - attempt: number; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - eventType: string; - /** Format: uuid */ - id: string; - payload: { - [key: string]: unknown; + requestBody?: never; + responses: { + /** @description Subscriber lists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - responseBody?: string | null; - responseStatus?: number | null; - /** @enum {string} */ - status: "PENDING" | "SUCCESS" | "FAILED"; - /** Format: uuid */ - webhookId: string; - }; - /** @description Cursor-paginated list of recent calls for a single webhook. */ - WebhookCallsListResponse: { - cursor?: string | null; - data: components["schemas"]["WebhookCall"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; - }; - /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ - WebhookCreateResponse: { - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { - /** @description Plaintext shared secret. Returned ONCE on create. */ - secret: string; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Single webhook (no secret). */ - WebhookGetResponse: { - data: components["schemas"]["Webhook"]; - /** @enum {boolean} */ - success: true; }; - /** @description List of webhooks for the auth'd project. */ - WebhookListResponse: { - data: components["schemas"]["Webhook"][]; - /** @enum {boolean} */ - success: true; + }; + v1CreateList: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ - WebhookRotateSecretResponse: { - data: { - /** Format: uuid */ - id: string; - /** @description New plaintext shared secret. */ - secret: string; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Create"]; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/v1/workflows. */ - WorkflowCreateV1: { - allow_reentry?: boolean; - description?: string; - /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ - enabled?: boolean; - /** @description The custom event that starts this workflow, e.g. `user.signup`. */ - event_name: string; - name: string; - }; - /** @description Confirmation that a workflow was deleted. */ - WorkflowDeletedV1: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; }; - /** @description Body for POST /api/v1/workflows/{id}/executions. */ - WorkflowExecutionStartV1: { - /** - * Format: uuid - * @description Contact to enter the workflow. Must belong to this project. - */ - contact_id: string; - /** @description Extra variables merged into the contact's data for this run. */ - context?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + responses: { + /** @description The created list */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1"]; + }; }; - }; - /** @description One contact's run through a workflow. */ - WorkflowExecutionV1: { - /** Format: date-time */ - completed_at: string | null; - /** Format: uuid */ - contact_id: string; - /** Format: uuid */ - current_step_id: string | null; - exit_reason: string | null; - /** Format: uuid */ - id: string; - /** Format: date-time */ - started_at: string; - /** @enum {string} */ - status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - /** Format: uuid */ - workflow_id: string; - }; - /** @description Cursor-paginated list of workflow executions, newest first. */ - WorkflowExecutionV1List: { - data: components["schemas"]["WorkflowExecutionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description Execution, email and conversion totals for one workflow. */ - WorkflowStatsV1: { - avg_duration_ms: number | null; - /** @description Execution counts keyed by status; a status with no executions is absent. */ - by_status: { - [key: string]: number; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ - completion_rate: number | null; - conversions: { - count: number; - event_name: string; - /** Format: uuid */ - goal_id: string; - name: string; - }[]; - emails: { - clicked: number; - opened: number; - sent: number; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - total: number; - /** Format: uuid */ - workflow_id: string; - }; - /** @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. */ - WorkflowUpdateV1: { - allow_reentry?: boolean; - description?: string; - enabled?: boolean; - event_name?: string; - /** @description Per-workflow start rate cap. `null` removes the cap. */ - max_executions_per_hour?: number | null; - name?: string; }; - /** @description An automation workflow as exposed on the v1 API. */ - WorkflowV1: { - allow_reentry: boolean; - /** Format: date-time */ - created_at: string; - description: string | null; - enabled: boolean; - /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ - event_name: string | null; - /** Format: uuid */ - id: string; - max_executions_per_hour: number | null; - name: string; - /** @enum {string} */ - trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; - /** Format: date-time */ - updated_at: string; - /** @description Incremented on every structural (step/transition) change. */ - version: number; + }; + v1GetList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description Cursor-paginated list of workflows. */ - WorkflowV1List: { - data: components["schemas"]["WorkflowV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + requestBody?: never; + responses: { + /** @description The list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -interface operations { - listContacts: { + v1DeleteList: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - subscribed?: "true" | "false"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact list */ + /** @description List deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactListResponse"]; + "application/json": components["schemas"]["ListV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createContact: { + v1UpdateList: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateContact"]; + "application/json": components["schemas"]["ListV1Update"]; }; }; responses: { - /** @description Contact created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: components["schemas"]["Contact"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description The updated list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Email already exists for this project */ - 409: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - bulkCreateContacts: { + listDomains: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactBulkCreateBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Bulk-create result */ + /** @description Domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - created: number; - errors: { - index: number; - message: string; - }[]; - skipped: number; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainListResponse"]; }; }; /** @description Validation error */ @@ -3175,15 +10147,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3204,7 +10167,7 @@ interface operations { }; }; }; - bulkDeleteContacts: { + addDomain: { parameters: { query?: never; header?: never; @@ -3213,22 +10176,20 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContactBulkDeleteBody"]; + "application/json": components["schemas"]["AddDomainBody"]; }; }; responses: { - /** @description Bulk-delete result */ - 200: { + /** @description Domain added */ + 201: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: { - deleted: number; - }; /** @enum {boolean} */ success: true; + data: components["schemas"]["Domain"]; }; }; }; @@ -3259,8 +10220,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; @@ -3268,8 +10229,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; @@ -3277,8 +10238,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Internal server error */ - 500: { + /** @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. */ + 502: { headers: { [name: string]: unknown; }; @@ -3288,29 +10249,27 @@ interface operations { }; }; }; - upsertContact: { + getDomain: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateContact"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Contact created or updated */ + /** @description Domain */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Contact"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Domain"]; }; }; }; @@ -3341,8 +10300,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -3370,7 +10329,7 @@ interface operations { }; }; }; - getContact: { + deleteDomain: { parameters: { query?: never; header?: never; @@ -3381,17 +10340,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Contact */ + /** @description Domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Contact"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; /** @description Validation error */ @@ -3450,7 +10405,7 @@ interface operations { }; }; }; - deleteContact: { + assignDomainStream: { parameters: { query?: never; header?: never; @@ -3459,15 +10414,23 @@ interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AssignDomainStream"]; + }; + }; responses: { - /** @description Contact deleted */ + /** @description Updated sending identity */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Domain"]; + }; }; }; /** @description Validation error */ @@ -3526,7 +10489,7 @@ interface operations { }; }; }; - updateContact: { + getDomainVerification: { parameters: { query?: never; header?: never; @@ -3535,22 +10498,18 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateContactBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated contact */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Contact"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["DomainVerificationStatus"]; }; }; }; @@ -3590,15 +10549,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3619,22 +10569,28 @@ interface operations { }; }; }; - listDomains: { + verifyDomain: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain list */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainListResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["DomainVerificationStatus"]; + }; }; }; /** @description Validation error */ @@ -3664,6 +10620,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3684,29 +10649,36 @@ interface operations { }; }; }; - addDomain: { + startDomainSetup: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddDomainBody"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Domain added */ - 201: { + /** @description Guided setup session */ + 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Domain"]; /** @enum {boolean} */ success: true; + data: { + token: string; + /** + * Format: uri + * @description Open this in a browser to publish the records. Short-lived and domain-specific. + */ + connectUrl: string; + /** @description When `connectUrl` stops working. */ + expiresAt: string; + }; }; }; }; @@ -3737,8 +10709,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -3746,8 +10718,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Internal server error */ - 500: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; @@ -3755,8 +10727,8 @@ interface operations { "application/json": 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. */ - 502: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; @@ -3766,419 +10738,409 @@ interface operations { }; }; }; - getDomain: { + v1ListDomains: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain */ + /** @description Sending domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Domain"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DomainV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteDomain: { + v1CreateDomain: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DomainV1Create"]; + }; + }; responses: { - /** @description Domain removed */ - 200: { + /** @description The registered sending domain, awaiting DNS */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `conflict` — this domain is already registered to a project you can send from. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - startDomainSetup: { + v1GetDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Guided setup session */ + /** @description The sending domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - /** - * Format: uri - * @description Open this in a browser to publish the records. Short-lived and domain-specific. - */ - connectUrl: string; - /** @description When `connectUrl` stops working. */ - expiresAt: string; - token: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomainVerification: { + v1DeleteDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description Sending domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["DomainVerificationStatus"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - verifyDomain: { + v1VerifyDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The sending domain, as SES and DNS now report it */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["DomainVerificationStatus"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listEmails: { + listTemplates: { parameters: { query?: { limit?: number; cursor?: string; - tag?: string; - status?: "PENDING" | "SENT" | "DELIVERED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED"; - from?: string; + search?: string; + emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; }; header?: never; path?: never; @@ -4186,13 +11148,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Email list */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailListResponse"]; + "application/json": components["schemas"]["TemplateListResponse"]; }; }; /** @description Validation error */ @@ -4222,6 +11184,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -4242,32 +11213,33 @@ interface operations { }; }; }; - sendEmail: { + createTemplate: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SendEmail"]; + "application/json": components["schemas"]["CreateTemplate"]; }; }; responses: { - /** @description Email accepted / sent */ - 200: { + /** @description Template created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SendEmailResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -4285,7 +11257,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -4294,16 +11266,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; @@ -4330,51 +11293,33 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - sendEmailBatch: { + getTemplate: { parameters: { query?: never; - header?: { - "Idempotency-Key"?: string; + header?: never; + path: { + id: string; }; - path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["BatchSendBody"]; - }; - }; + requestBody?: never; responses: { - /** @description All entries sent */ + /** @description Template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BatchSendResponse"]; - }; - }; - /** @description Partial success — at least one entry failed */ - 207: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BatchSendResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -4392,7 +11337,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -4401,17 +11346,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -4437,18 +11373,9 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - getEmail: { + deleteTemplate: { parameters: { query?: never; header?: never; @@ -4459,13 +11386,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Email */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailGetResponse"]; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -4504,6 +11431,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Template still in use */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -4524,7 +11460,7 @@ interface operations { }; }; }; - cancelScheduledEmail: { + updateTemplate: { parameters: { query?: never; header?: never; @@ -4533,15 +11469,23 @@ interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTemplate"]; + }; + }; responses: { - /** @description Email cancelled */ + /** @description Updated template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailGetResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; /** @description Validation error */ @@ -4580,8 +11524,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already past PENDING */ - 409: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -4609,386 +11553,411 @@ interface operations { }; }; }; - subscribeToList: { + v1ListTemplates: { parameters: { - query?: never; - header?: never; - path: { - /** @description List id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the name. */ + search?: string; + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListSubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact subscribed, or an existing membership returned unchanged */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSubscribeResponse"]; + "application/json": components["schemas"]["TemplateV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateV1Create"]; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + }; + responses: { + /** @description The created template */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ - 409: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - unsubscribeFromList: { + v1GetTemplate: { parameters: { query?: never; header?: never; path: { - /** @description List id. */ + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListUnsubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact unsubscribed */ + /** @description The template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUnsubscribeResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listMailboxes: { + v1DeleteTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox list */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Mailbox"][]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["TemplateV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createMailbox: { + v1UpdateTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateMailboxBody"]; + "application/json": components["schemas"]["TemplateV1Update"]; }; }; responses: { - /** @description Mailbox provisioned */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: components["schemas"]["Mailbox"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description The updated template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getMailbox: { + listSnippets: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + cursor?: string; + search?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox with connection settings */ + /** @description Snippet list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["MailboxDetail"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["SnippetListResponse"]; }; }; /** @description Validation error */ @@ -5018,8 +11987,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -5047,30 +12016,29 @@ interface operations { }; }; }; - deleteMailbox: { + createSnippet: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSnippet"]; + }; + }; responses: { - /** @description Mailbox deleted */ - 200: { + /** @description Snippet created */ + 201: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: { - /** @enum {boolean} */ - deleted: true; - }; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5101,8 +12069,17 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -5130,7 +12107,7 @@ interface operations { }; }; }; - listAppPasswords: { + getSnippet: { parameters: { query?: never; header?: never; @@ -5141,16 +12118,16 @@ interface operations { }; requestBody?: never; responses: { - /** @description App password list */ + /** @description Snippet */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["AppPassword"][]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5210,7 +12187,7 @@ interface operations { }; }; }; - createAppPassword: { + deleteSnippet: { parameters: { query?: never; header?: never; @@ -5219,23 +12196,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateAppPassword"]; - }; - }; - responses: { - /** @description App password created; the secret is behind the one-time link */ - 201: { + requestBody?: never; + responses: { + /** @description Snippet deleted */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["AppPasswordReveal"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -5294,31 +12263,31 @@ interface operations { }; }; }; - revokeAppPassword: { + updateSnippet: { parameters: { query?: never; header?: never; path: { id: string; - passwordId: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSnippet"]; + }; + }; responses: { - /** @description App password revoked */ + /** @description Updated snippet */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: { - /** @enum {boolean} */ - revoked: true; - }; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5358,6 +12327,24 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5378,25 +12365,22 @@ interface operations { }; }; }; - listApiKeys: { + listWebhooks: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description API key list */ + /** @description Webhook list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiKeyListResponse"]; + "application/json": components["schemas"]["WebhookListResponse"]; }; }; /** @description Validation error */ @@ -5426,15 +12410,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5455,45 +12430,26 @@ interface operations { }; }; }; - createApiKey: { + createWebhook: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateApiKeyBody"]; + "application/json": components["schemas"]["CreateWebhook"]; }; }; responses: { - /** @description API key created; the secret is behind the reveal link. */ + /** @description Webhook created */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @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. */ - data: components["schemas"]["ApiKey"] & { - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookCreateResponse"]; }; }; /** @description Validation error */ @@ -5543,27 +12499,24 @@ interface operations { }; }; }; - revokeApiKey: { + getWebhook: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ id: string; - /** @description API key id. */ - keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key revoked */ + /** @description Webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; /** @description Validation error */ @@ -5622,43 +12575,24 @@ interface operations { }; }; }; - rotateApiKey: { + deleteWebhook: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ id: string; - /** @description API key id. */ - keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key rotated; the new secret is behind the reveal link. */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - lastFour: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; /** @description Validation error */ @@ -5717,26 +12651,28 @@ interface operations { }; }; }; - listSuppressions: { + updateWebhook: { parameters: { - query?: { - limit?: number; - cursor?: string; - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebhook"]; + }; + }; responses: { - /** @description Suppression list */ + /** @description Webhook updated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionListResponse"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; /** @description Validation error */ @@ -5766,6 +12702,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5786,26 +12731,24 @@ interface operations { }; }; }; - addSuppression: { + rotateWebhookSecret: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddSuppression"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Suppression added */ - 201: { + /** @description Secret rotated */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Suppression"]; + "application/json": components["schemas"]["WebhookRotateSecretResponse"]; }; }; /** @description Validation error */ @@ -5835,6 +12778,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5855,25 +12807,27 @@ interface operations { }; }; }; - checkSuppression: { + listWebhookCalls: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + }; header?: never; path: { - /** @description URL-encoded email address */ - email: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression check result */ + /** @description Webhook call history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionCheckResponse"]; + "application/json": components["schemas"]["WebhookCallsListResponse"]; }; }; /** @description Validation error */ @@ -5903,6 +12857,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5923,511 +12886,475 @@ interface operations { }; }; }; - removeSuppression: { + v1ListWebhooks: { parameters: { - query?: never; - header?: never; - path: { - /** @description URL-encoded email address */ - email: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression removed */ - 204: { + /** @description Webhook list */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["WebhookV1List"]; + }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listTemplates: { + v1CreateWebhook: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - type?: "MARKETING" | "TRANSACTIONAL" | "HEADLESS"; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Create"]; + }; + }; responses: { - /** @description Template list */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TemplateListResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The created webhook and its one-time signing secret */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1Created"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createTemplate: { + v1GetWebhook: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateTemplate"]; + path: { + /** @description Resource id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Template created */ - 201: { + /** @description The webhook */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getTemplate: { + v1DeleteWebhook: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Template */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteTemplate: { + v1UpdateWebhook: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Update"]; + }; + }; responses: { - /** @description Template deleted */ + /** @description The updated webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Template still in use */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateTemplate: { + v1RotateWebhookSecret: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateTemplate"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated template */ + /** @description The new signing secret and the moment the previous one stops verifying */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1SecretRotated"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - trackEvent: { + listApiKeys: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TrackEvent"]; + path: { + /** @description Project id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Event tracked */ + /** @description API key list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TrackEventResponse"]; + "application/json": components["schemas"]["ApiKeyListResponse"]; }; }; /** @description Validation error */ @@ -6457,6 +13384,15 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -6477,33 +13413,45 @@ interface operations { }; }; }; - createProject: { + createApiKey: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": { - name: string; - /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. - * @enum {string} - */ - sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; + "application/json": components["schemas"]["CreateApiKeyBody"]; }; }; responses: { - /** @description Project created */ + /** @description API key created; the secret is behind the reveal link. */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectRecord"]; + "application/json": { + /** @enum {boolean} */ + success: true; + /** @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. */ + data: components["schemas"]["ApiKey"] & { + /** + * Format: uri + * @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. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + }; + }; }; }; /** @description Validation error */ @@ -6533,15 +13481,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -6562,154 +13501,186 @@ interface operations { }; }; }; - v1GetCampaignAnalytics: { + rotateApiKey: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description API key rotated; the new secret is behind the reveal link. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + lastFour: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetAnalyticsTimeseries: { + revokeApiKey: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Daily time series */ + /** @description API key revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListTopCampaigns: { + listSuppressions: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; limit?: number; + cursor?: string; + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; header?: never; path?: never; @@ -6717,326 +13688,287 @@ interface operations { }; requestBody?: never; responses: { - /** @description Ranked campaigns */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; + "application/json": components["schemas"]["SuppressionListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListCampaigns: { + addSuppression: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Campaign list */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CampaignV1List"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + requestBody: { + content: { + "application/json": components["schemas"]["AddSuppression"]; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + responses: { + /** @description Suppression added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Suppression"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - v1CreateCampaign: { - parameters: { - query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CampaignV1Create"]; + "application/json": components["schemas"]["Error"]; + }; }; - }; - responses: { - /** @description Campaign created */ - 201: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + }; + checkSuppression: { + parameters: { + query?: never; + header?: never; + path: { + /** @description URL-encoded email address */ + email: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suppression check result */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionCheckResponse"]; }; }; - /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetCampaign: { + removeSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The campaign */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Suppression removed */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + content?: never; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteCampaign: { + v1ListSuppressions: { parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Filter to one reason. Omit for every suppressed address. */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign deleted */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Deleted"]; - }; - }; - /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7057,15 +13989,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7095,38 +14018,26 @@ interface operations { }; }; }; - v1UpdateCampaign: { + v1CreateSuppression: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Update"]; + "application/json": components["schemas"]["SuppressionV1Create"]; }; }; responses: { - /** @description The updated campaign */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ - 400: { + /** @description The suppressed address */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7147,15 +14058,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7185,34 +14087,25 @@ interface operations { }; }; }; - v1CancelCampaign: { + v1GetSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The cancelled campaign */ + /** @description The suppression record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7233,7 +14126,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -7271,34 +14164,25 @@ interface operations { }; }; }; - v1PauseCampaign: { + v1DeleteSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The paused campaign */ + /** @description Address removed from the suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — only a `SENDING` campaign can be paused. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7319,15 +14203,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7357,127 +14232,97 @@ interface operations { }; }; }; - v1ResumeCampaign: { + trackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TrackEvent"]; + }; + }; responses: { - /** @description The resumed campaign */ + /** @description Event tracked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["TrackEventResponse"]; }; }; - /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendCampaign: { + v1ListEvents: { parameters: { - query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only events with this exact name. */ + event_name?: string; }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["CampaignV1Send"]; - }; - }; + requestBody?: never; responses: { - /** @description The campaign, now `SENDING` or `SCHEDULED` */ + /** @description Event list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["EventV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7498,25 +14343,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; @@ -7545,25 +14372,26 @@ interface operations { }; }; }; - v1GetCampaignStats: { + v1TrackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EventTrackV1"]; + }; + }; responses: { - /** @description Campaign statistics */ - 200: { + /** @description Event recorded */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Stats"]; + "application/json": components["schemas"]["EventV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7584,7 +14412,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -7622,29 +14450,22 @@ interface operations { }; }; }; - v1SendEmail: { + v1ListEventNames: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendEmailV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Email queued */ - 202: { + /** @description Event names */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailV1"]; + "application/json": components["schemas"]["EventNamesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7656,7 +14477,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; @@ -7665,25 +14486,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; @@ -7710,37 +14513,29 @@ interface operations { "application/problem+json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1SendTestEmail: { + v1GetEventStats: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendTestEmailV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Test email queued */ - 202: { + /** @description Event counts */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailTestV1"]; + "application/json": components["schemas"]["EventStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7752,7 +14547,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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`. */ 403: { headers: { [name: string]: unknown; @@ -7761,16 +14556,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @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. */ 422: { headers: { [name: string]: unknown; @@ -7779,7 +14565,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 429: { headers: { [name: string]: unknown; @@ -7797,25 +14583,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1ListEvents: { + v1GetAnalyticsTimeseries: { parameters: { query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only events with this exact name. */ - event_name?: string; + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; header?: never; path?: never; @@ -7823,13 +14599,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Event list */ + /** @description Daily time series */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1List"]; + "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7879,26 +14655,27 @@ interface operations { }; }; }; - v1TrackEvent: { + v1GetCampaignAnalytics: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["EventTrackV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Event recorded */ - 201: { + /** @description Campaign statistics */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1"]; + "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7919,15 +14696,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no contact with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7957,22 +14725,28 @@ interface operations { }; }; }; - v1ListEventNames: { + v1ListTopCampaigns: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + limit?: number; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Event names */ + /** @description Ranked campaigns */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventNamesV1"]; + "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8022,13 +14796,15 @@ interface operations { }; }; }; - v1GetEventStats: { - parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + v1DiagnoseDeliverability: { + parameters: { + query: { + /** @description A sending domain in this project, e.g. `example.com`. */ + domain: 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. */ + address?: string; + /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ + window_days?: number; }; header?: never; path?: never; @@ -8036,13 +14812,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Event counts */ + /** @description The diagnosis, with findings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStatsV1"]; + "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8092,22 +14868,30 @@ interface operations { }; }; }; - v1GetProject: { + v1ListRecipientDomainStats: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ + days?: number; + /** @description Restrict to one recipient domain. */ + domain?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The authenticated project */ + /** @description Cursor-paginated recipient-domain rollup */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectV1"]; + "application/json": components["schemas"]["RecipientDomainStatsV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8128,15 +14912,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — the project was deleted between authentication and this read. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -8166,12 +14941,16 @@ interface operations { }; }; }; - v1ListSegments: { + v1ListDmarcReports: { parameters: { query?: { limit?: number; /** @description Opaque cursor from a previous response's `next_cursor`. */ after?: string; + /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ + days?: number; + /** @description Restrict to reports about one of your domains. */ + domain?: string; }; header?: never; path?: never; @@ -8179,13 +14958,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Segment list */ + /** @description Cursor-paginated DMARC aggregate reports */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1List"]; + "application/json": components["schemas"]["DmarcReportV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8235,35 +15014,22 @@ interface operations { }; }; }; - v1CreateSegment: { + v1GetUsage: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SegmentV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description Segment created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SegmentV1"]; - }; - }; - /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ - 400: { + /** @description Current usage */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["UsageV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8313,25 +15079,22 @@ interface operations { }; }; }; - v1GetSegment: { + v1GetProject: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The segment */ + /** @description The authenticated project */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": components["schemas"]["ProjectV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8352,7 +15115,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — the project was deleted between authentication and this read. */ 404: { headers: { [name: string]: unknown; @@ -8390,812 +15153,962 @@ interface operations { }; }; }; - v1DeleteSegment: { + listMailboxes: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment deleted */ + /** @description Mailbox list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1Deleted"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Mailbox"][]; + }; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the segment is still used by one or more active campaigns. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateSegment: { + createMailbox: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SegmentV1Update"]; + "application/json": components["schemas"]["CreateMailboxBody"]; }; }; responses: { - /** @description The updated segment */ - 200: { + /** @description Mailbox provisioned */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Mailbox"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegmentContacts: { + getMailbox: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment member list */ + /** @description Mailbox with connection settings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentContactV1List"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["MailboxDetail"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetUsage: { + deleteMailbox: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Current usage */ + /** @description Mailbox deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UsageV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + deleted: true; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflows: { + draftMailboxMessage: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DraftMailboxMessage"]; + }; + }; responses: { - /** @description Workflow list */ + /** @description A draft. Nothing was sent. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1List"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @description Suggested subject, or null. */ + subject: string | null; + /** @description Suggested plain-text body, or null. */ + body: string | null; + /** @description Alternative subject lines (`subject` mode); empty otherwise. */ + subjects: string[]; + /** + * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. + * @enum {boolean} + */ + sent: false; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The drafting model was unreachable or returned nothing usable. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateWorkflow: { + sendMailboxMessage: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowCreateV1"]; + "application/json": components["schemas"]["ComposeMailboxMessage"]; }; }; responses: { - /** @description Workflow created */ + /** @description Message submitted */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + submitted: true; + /** + * Format: uuid + * @description The conversation this send started. Replies thread onto it. + */ + conversationId: string; + /** + * Format: uuid + * @description The stored outbound message. + */ + messageId: string; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The mail server refused the submission. Nothing was sent. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelWorkflowExecution: { + listAppPasswords: { parameters: { query?: never; header?: never; path: { - /** @description Workflow execution id. */ - execution_id: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Cancelled execution */ + /** @description App password list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["AppPassword"][]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no execution with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflow: { + createAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateAppPassword"]; + }; + }; responses: { - /** @description Workflow */ - 200: { + /** @description App password created; the secret is behind the one-time link */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["AppPasswordReveal"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteWorkflow: { + revokeAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; + passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow deleted */ + /** @description App password revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowDeletedV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + revoked: true; + }; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow still has running executions. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateWorkflow: { + createProject: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowUpdateV1"]; + "application/json": { + name: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; + }; }; }; responses: { - /** @description Updated workflow */ - 200: { + /** @description Project created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["ProjectRecord"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the trigger cannot be changed while executions are running. */ - 409: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + }; + }; + verifyEmailAddress: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VerifyEmail"]; + }; + }; + responses: { + /** @description Verification result */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["VerifyEmailResponse"]; }; }; - /** @description `internal_error`. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflowExecutions: { + v1ListTopics: { parameters: { query?: { + cursor?: string; limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only executions in this state. */ - status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + include_archived?: boolean | null; }; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Execution list */ + /** @description One page of topics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1List"]; + "application/json": components["schemas"]["TopicListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9216,15 +16129,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9254,29 +16158,26 @@ interface operations { }; }; }; - v1StartWorkflowExecution: { + v1CreateTopic: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowExecutionStartV1"]; + "application/json": components["schemas"]["TopicCreateV1"]; }; }; responses: { - /** @description Execution started */ + /** @description The created topic */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9297,24 +16198,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9344,28 +16227,25 @@ interface operations { }; }; }; - v1GetWorkflowStats: { + v1GetTopic: { parameters: { - query?: { - /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ - from?: string | null; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description The topic. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow statistics */ + /** @description The topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStatsV1"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9386,15 +16266,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9424,565 +16295,492 @@ interface operations { }; }; }; - verifyEmailAddress: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["VerifyEmail"]; - }; - }; - responses: { - /** @description Verification result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VerifyEmailResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - }; - }; - listWebhooks: { + v1UpdateTopic: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicUpdateV1"]; + }; + }; responses: { - /** @description Webhook list */ + /** @description The updated topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookListResponse"]; + "application/json": components["schemas"]["TopicV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createWebhook: { + v1SetTopicSubscription: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateWebhook"]; + "application/json": components["schemas"]["TopicSubscribeV1"]; }; }; responses: { - /** @description Webhook created */ - 201: { + /** @description The resulting subscription */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCreateResponse"]; + "application/json": components["schemas"]["TopicSubscriptionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getWebhook: { + v1GetContactTopicPreferences: { parameters: { query?: never; header?: never; path: { + /** @description The contact. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook */ + /** @description The contact's preferences */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ContactTopicPreferencesV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteWebhook: { + v1ValidateEmails: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + }; + }; responses: { - /** @description Webhook deleted */ + /** @description One verdict per address, in the order they were given */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationBatchV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateWebhook: { + v1StartListValidationRun: { parameters: { query?: never; header?: never; path: { + /** @description The list to validate. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook updated */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The run, accepted and queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhookCalls: { + v1GetValidationRun: { parameters: { - query?: { - limit?: number; - cursor?: string; - }; + query?: never; header?: never; path: { + /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook call history */ + /** @description The run */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCallsListResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateWebhookSecret: { + v1ListValidationRunResults: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ + verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; + }; header?: never; path: { + /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Secret rotated */ + /** @description One page of results */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookRotateSecretResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationResultListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; @@ -10009,7 +16807,23 @@ type BatchSendResponse = components["schemas"]["BatchSendResponse"]; type BatchEntryResult = components["schemas"]["BatchEntryResult"]; type EmailRecord = components["schemas"]["Email"]; type EmailListResponse = components["schemas"]["EmailListResponse"]; -type EmailGetResponse = components["schemas"]["EmailGetResponse"]; +/** + * 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. + */ +type EmailEvent = components["schemas"]["EmailEvent"]; +/** An email together with its delivery history, oldest first. */ +type EmailWithEvents = components["schemas"]["EmailWithEvents"]; +/** + * A single email with no history — what `emails.cancelSchedule` resolves. + * + * Was `EmailGetResponse` in 1.0, which named the operation rather than the + * shape and was then reused by an operation that is not a GET. + */ +type EmailResponse = components["schemas"]["EmailResponse"]; +/** `emails.get` — one email plus its delivery events. */ +type EmailDetailResponse = components["schemas"]["EmailDetailResponse"]; type ListEmailsQuery = NonNullable; type ContactRecord = components["schemas"]["Contact"]; type ContactListResponse = components["schemas"]["ContactListResponse"]; @@ -10021,6 +16835,8 @@ type ListContactsQuery = NonNullable; type SuppressionRecord = components["schemas"]["Suppression"]; type SuppressionListResponse = components["schemas"]["SuppressionListResponse"]; type SuppressionCheckResponse = components["schemas"]["SuppressionCheckResponse"]; @@ -10069,8 +16901,8 @@ type CampaignV1 = components["schemas"]["CampaignV1"]; type CampaignListV1 = components["schemas"]["CampaignV1List"]; type CampaignDeletedV1 = components["schemas"]["CampaignV1Deleted"]; type CampaignStatsV1 = components["schemas"]["CampaignV1Stats"]; -/** `type` defaults to `MARKETING` server-side, so it is optional here. */ -type CreateCampaignV1Request = PartialKeys; +/** `email_category` defaults to `MARKETING` server-side, so it is optional here. */ +type CreateCampaignV1Request = PartialKeys; type UpdateCampaignV1Request = components["schemas"]["CampaignV1Update"]; type SendCampaignV1Request = components["schemas"]["CampaignV1Send"]; type ListCampaignsV1Query = NonNullable; @@ -10116,6 +16948,92 @@ type EmailTestV1 = components["schemas"]["EmailTestV1"]; type AnalyticsTimeseriesV1Query = NonNullable; type AnalyticsCampaignsV1Query = NonNullable; type ListTopCampaignsV1Query = NonNullable; +type ContactV1 = components["schemas"]["ContactV1"]; +type ContactListV1 = components["schemas"]["ContactV1List"]; +type ContactDeletedV1 = components["schemas"]["ContactV1Deleted"]; +/** `subscribed` defaults to `true` server-side, so it is optional here. */ +type CreateContactV1Request = PartialKeys; +type UpdateContactV1Request = components["schemas"]["ContactV1Update"]; +/** Everything one contact has said they want, topic by topic. */ +type ContactTopicPreferencesV1 = components["schemas"]["ContactTopicPreferencesV1"]; +type ListContactsV1Query = NonNullable; +type ListV1 = components["schemas"]["ListV1"]; +type ListListV1 = components["schemas"]["ListV1List"]; +type ListDeletedV1 = components["schemas"]["ListV1Deleted"]; +/** `double_opt_in` defaults to `false` server-side, so it is optional here. */ +type CreateListV1Request = PartialKeys; +type UpdateListV1Request = components["schemas"]["ListV1Update"]; +type ListListsV1Query = NonNullable; +type TemplateV1 = components["schemas"]["TemplateV1"]; +type TemplateListV1 = components["schemas"]["TemplateV1List"]; +type TemplateDeletedV1 = components["schemas"]["TemplateV1Deleted"]; +/** `email_category` defaults to `MARKETING` server-side, so it is optional here. */ +type CreateTemplateV1Request = PartialKeys; +type UpdateTemplateV1Request = components["schemas"]["TemplateV1Update"]; +type ListTemplatesV1Query = NonNullable; +type DomainV1 = components["schemas"]["DomainV1"]; +type DomainListV1 = components["schemas"]["DomainV1List"]; +type DomainDeletedV1 = components["schemas"]["DomainV1Deleted"]; +type CreateDomainV1Request = components["schemas"]["DomainV1Create"]; +type ListDomainsV1Query = NonNullable; +type WebhookV1 = components["schemas"]["WebhookV1"]; +type WebhookListV1 = components["schemas"]["WebhookV1List"]; +type WebhookDeletedV1 = components["schemas"]["WebhookV1Deleted"]; +/** The create response, and the only time the signing secret is readable. */ +type WebhookCreatedV1 = components["schemas"]["WebhookV1Created"]; +/** Rotation answers the new secret once, for the same reason. */ +type WebhookSecretRotatedV1 = components["schemas"]["WebhookV1SecretRotated"]; +type CreateWebhookV1Request = components["schemas"]["WebhookV1Create"]; +type UpdateWebhookV1Request = components["schemas"]["WebhookV1Update"]; +type ListWebhooksV1Query = NonNullable; +type SuppressionV1 = components["schemas"]["SuppressionV1"]; +type SuppressionListV1 = components["schemas"]["SuppressionV1List"]; +type SuppressionDeletedV1 = components["schemas"]["SuppressionV1Deleted"]; +/** `reason` defaults to `MANUAL` server-side, so it is optional here. */ +type CreateSuppressionV1Request = PartialKeys; +type ListSuppressionsV1Query = NonNullable; +type TopicV1 = components["schemas"]["TopicV1"]; +type TopicListV1 = components["schemas"]["TopicListV1"]; +type CreateTopicV1Request = components["schemas"]["TopicCreateV1"]; +type UpdateTopicV1Request = components["schemas"]["TopicUpdateV1"]; +type SetTopicSubscriptionV1Request = components["schemas"]["TopicSubscribeV1"]; +type TopicSubscriptionV1 = components["schemas"]["TopicSubscriptionV1"]; +type TopicSubscriptionStatusV1 = components["schemas"]["TopicSubscriptionStatusV1"]; +type ListTopicsV1Query = NonNullable; +type ValidateEmailsV1Request = components["schemas"]["EmailValidationBatchRequestV1"]; +type EmailValidationBatchV1 = components["schemas"]["EmailValidationBatchV1"]; +type EmailValidationV1 = components["schemas"]["EmailValidationV1"]; +type EmailValidationVerdictV1 = components["schemas"]["EmailValidationVerdictV1"]; +type EmailValidationRunV1 = components["schemas"]["EmailValidationRunV1"]; +type EmailValidationResultListV1 = components["schemas"]["EmailValidationResultListV1"]; +/** + * One address's verdict inside a run's results — a validation plus the + * `contact_id` it came from. The spec composes it inline rather than naming a + * component, so it is read off the page it appears in. + */ +type EmailValidationResultV1 = EmailValidationResultListV1["data"][number]; +type ListValidationResultsV1Query = NonNullable; +type DeliverabilityDiagnosisV1 = components["schemas"]["DeliverabilityDiagnosisV1"]; +type DeliverabilityFindingV1 = components["schemas"]["DeliverabilityFindingV1"]; +type DeliverabilityFindingSeverityV1 = components["schemas"]["DeliverabilityFindingSeverityV1"]; +type DeliverabilityIdentityV1 = components["schemas"]["DeliverabilityIdentityV1"]; +type DeliverabilityRecentDeliveryV1 = components["schemas"]["DeliverabilityRecentDeliveryV1"]; +type DeliverabilitySuppressionV1 = components["schemas"]["DeliverabilitySuppressionV1"]; +type RecipientDomainStatsV1 = components["schemas"]["RecipientDomainStatsV1"]; +type RecipientDomainStatsListV1 = components["schemas"]["RecipientDomainStatsV1List"]; +type DmarcReportV1 = components["schemas"]["DmarcReportV1"]; +type DmarcReportListV1 = components["schemas"]["DmarcReportV1List"]; +type DiagnoseDeliverabilityV1Query = NonNullable; +type ListRecipientDomainStatsV1Query = NonNullable; +type ListDmarcReportsV1Query = NonNullable; +type CampaignFailureV1 = components["schemas"]["CampaignV1Failure"]; +type CampaignFailureListV1 = components["schemas"]["CampaignV1FailureList"]; +type CampaignRetryFailedV1 = components["schemas"]["CampaignV1RetryFailed"]; +type ListCampaignFailuresV1Query = NonNullable; +type WorkflowGraphV1 = components["schemas"]["WorkflowGraphV1"]; +type ReplaceWorkflowGraphV1Request = components["schemas"]["WorkflowGraphReplaceV1"]; +type CloneWorkflowV1Request = components["schemas"]["WorkflowCloneV1"]; +type WorkflowStateChangeV1 = components["schemas"]["WorkflowStateChangeV1"]; /** * Sending analytics on the `/api/v1` surface. @@ -10200,8 +17118,43 @@ declare class CampaignsResource { resume(id: string): Promise; /** Delivery and engagement counters plus derived rates for one campaign. */ stats(id: string): Promise; + /** + * The recipients this campaign did not reach, and why. + * + * {@link 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 it is `null` on rows recorded before + * reasons were captured. + * + * Cursor-paginated like every other v1 list, but uniquely it also carries + * `total`: {@link retryFailed} acts on that number, and `has_more` alone + * cannot tell you whether 3 or 30,000 sends failed. + */ + listFailures(id: string, query?: ListCampaignFailuresV1Query): Promise; + /** Iterate every failed send across pages, yielding one recipient at a time. */ + listFailuresAll(id: string, query?: ListCampaignFailuresV1Query): AsyncGenerator; + /** + * 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, not re-sent. + * + * The walk runs in the background, so this resolves 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. + */ + retryFailed(id: string): Promise; } +/** + * 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. + */ declare class ContactsResource { private readonly client; constructor(client: Sendly); @@ -10221,8 +17174,132 @@ declare class ContactsResource { update(id: string, body: UpdateContactRequest): Promise; /** Delete a contact. The API answers 200 with `{ success, data: { id } }`; the SDK resolves void. */ delete(id: string): Promise; + /** + * List contacts on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after` with no total count, narrowed by + * `search` (case-insensitive substring on the address) and `subscribed`. + * Hold the filters steady for the whole walk — the cursor encodes them, and + * changing one mid-pagination returns `422 validation_error` asking you to + * restart. {@link listAllV1} drives the loop for you. + */ + listV1(query?: ListContactsV1Query): Promise; + /** Iterate every v1 contact across pages, yielding one contact at a time. */ + listAllV1(query?: ListContactsV1Query): AsyncGenerator; + /** + * Create a contact. Only `email` is required — `subscribed` defaults to true + * server-side, and `custom_fields` is arbitrary JSON that templates can read + * back as `{{ variables }}`. + */ + createV1(body: CreateContactV1Request): Promise; + /** + * Retrieve a single contact by id. v1 has no lookup-by-address route — reach + * a contact you only know the email of through {@link listV1}'s `search`. + */ + getV1(id: string): Promise; + /** + * Patch a contact. Only the fields you send are changed, with two caveats. + * + * `email` is not patchable at all: an address is the contact's identity here, + * 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. Sending a partial object silently drops the rest. + */ + updateV1(id: string, body: UpdateContactV1Request): Promise; + /** + * Delete a contact. Unlike the legacy {@link delete}, this resolves the + * `{ id, deleted }` acknowledgement rather than discarding it. + */ + deleteV1(id: string): Promise; + /** + * 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 nothing 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. + */ + topicPreferences(id: string): Promise; +} + +/** + * Deliverability on the `/api/v1` surface — why mail from your domains is, or + * is not, arriving. + * + * Responses are bare v1 bodies (no `{ success, data }` envelope) and errors are + * RFC 9457 problem documents. + */ +declare class DeliverabilityResource { + private readonly client; + constructor(client: Sendly); + /** + * Diagnose one of your SENDING domains: 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. + * + * `query.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. + */ + diagnose(query: DiagnoseDeliverabilityV1Query): Promise; + /** + * Delivery outcomes broken out 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 {@link 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. + */ + listDomainStats(query?: ListRecipientDomainStatsV1Query): Promise; + /** Iterate every recipient-domain row across pages, one day-and-domain at a time. */ + listDomainStatsAll(query?: ListRecipientDomainStatsV1Query): AsyncGenerator; + /** + * DMARC aggregate (RUA) reports that receiving providers have sent about your + * domains, newest reporting 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). + * + * `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. + */ + listDmarcReports(query?: ListDmarcReportsV1Query): Promise; + /** Iterate every DMARC report across pages, one report at a time. */ + listDmarcReportsAll(query?: ListDmarcReportsV1Query): AsyncGenerator; } +/** + * 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. + */ declare class DomainsResource { private readonly client; constructor(client: Sendly); @@ -10254,8 +17331,77 @@ declare class DomainsResource { * back the link, not to model the flow behind it. */ startSetup(id: string): Promise; + /** + * 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: null` 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. + */ + assignStream(id: string, body: AssignDomainStreamRequest): Promise; /** Delete a domain. */ delete(id: string): Promise; + /** + * List sending domains, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. {@link listAllV1} + * drives the loop 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. + */ + listV1(query?: ListDomainsV1Query): Promise; + /** Iterate every sending domain across pages, yielding one domain at a time. */ + listAllV1(query?: ListDomainsV1Query): AsyncGenerator; + /** + * 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 {@link verifyV1} 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. + */ + createV1(body: CreateDomainV1Request): Promise; + /** Retrieve a single sending domain. */ + getV1(id: string): Promise; + /** + * Re-read the domain's state from SES and DNS, and resolve the refreshed + * document. + * + * 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. + */ + verifyV1(id: string): Promise; + /** + * Remove a sending domain. Resolves `{ id, deleted }`. + * + * 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. + */ + deleteV1(id: string): Promise; } declare class EmailsResource { @@ -10301,10 +17447,24 @@ declare class EmailsResource { batch(body: BatchSendRequest, opts?: IdempotencyOptions): Promise; /** List emails with cursor-based pagination + filters. */ list(query?: ListEmailsQuery): Promise; - /** Fetch a single email and its delivery events. */ - get(id: string): Promise; - /** Cancel a scheduled (PENDING) email before it fires. */ - cancelSchedule(id: string): Promise; + /** + * 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 along with it. + */ + get(id: string): Promise; + /** + * Cancel a scheduled (PENDING) email before it fires. + * + * Resolves the email itself, not an empty acknowledgement: the contract has + * always published `EmailResponse` here, and the caller wants the row's new + * status more than it wants a `{ success: true }` it already inferred from the + * absence of an exception. + */ + cancelSchedule(id: string): Promise; } /** @@ -10360,8 +17520,14 @@ declare class EventsResource { } /** - * Subscription management for a mailing list, on the legacy `/api/*` surface - * (envelope responses, camelCase — the SDK unwraps to `data`). + * Subscriber lists, on both surfaces. + * + * {@link subscribe} and {@link unsubscribe} speak the legacy `/api/*` dialect + * (camelCase inside a `{ success, data }` envelope the SDK unwraps) and accept + * SENDING_ONLY keys. The `V1`-suffixed methods manage the lists themselves on + * `/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. */ declare class ListsResource { private readonly client; @@ -10373,8 +17539,8 @@ declare class ListsResource { * **Double opt-in.** When the list has `doubleOptIn` enabled the membership * is created as `PENDING` and the result carries a `confirmToken`. Sendly * does **not** send the confirmation email — your application must deliver - * `/api/lists/confirm?token=` to the contact itself. The token - * is valid for 24 hours. + * `/api/lists/confirm-subscription?token=` to the contact + * itself. The token is valid for 24 hours. * * **Re-subscribing after an opt-out.** If the email already holds an * `UNSUBSCRIBED` membership on this list, the call fails with @@ -10390,22 +17556,70 @@ declare class ListsResource { subscribe(id: string, body: ListSubscribeRequest): Promise; /** Unsubscribe a contact from a list. Resolves the address that was removed. */ unsubscribe(id: string, body: ListUnsubscribeRequest): Promise; + /** + * List the project's subscriber lists on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold the + * arguments steady for the whole walk — changing them mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + listV1(query?: ListListsV1Query): Promise; + /** Iterate every list across pages, yielding one list at a time. */ + listAllV1(query?: ListListsV1Query): AsyncGenerator; + /** + * Create a list. Only `name` is required; `double_opt_in` defaults to false. + * + * Turning double opt-in on does not make Sendly send anything — it only + * changes {@link subscribe} to create the membership as `PENDING` and hand + * back the `confirmToken` your application delivers. + */ + createV1(body: CreateListV1Request): Promise; + /** + * Retrieve 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. + */ + getV1(id: string): Promise; + /** + * 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. + */ + updateV1(id: string, body: UpdateListV1Request): Promise; + /** Delete a list. Resolves `{ id, deleted }`. Removes the list, not its contacts. */ + deleteV1(id: string): Promise; + /** + * 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.getRun`. + */ + startValidationRun(id: string): Promise; } +/** What {@link MailboxesResource.sendMessage} resolves once the message is submitted. */ +type MailboxMessageSubmitted = paths["/api/mailboxes/{id}/messages"]["post"]["responses"][201]["content"]["application/json"]["data"]; +/** What {@link MailboxesResource.draftMessage} resolves — suggested text, and `sent: false`. */ +type MailboxMessageDraft = paths["/api/mailboxes/{id}/drafts"]["post"]["responses"][200]["content"]["application/json"]["data"]; /** - * Receiving mailboxes on the project's verified domains. + * Receiving mailboxes on the project's verified domains, plus the two + * composition operations an API key may drive. * - * READ ONLY, and deliberately so. Creating and deleting a mailbox, and minting - * or revoking an app password, all resolve the acting project admin from the - * session user; an API key carries no user, so those routes answer `401` to any - * `sk_` key however broad its scopes. The contract records that — they publish - * `SessionAuth` without `ApiKeyAuth` — and this SDK authenticates only with API - * keys, so a `create`/`delete` here could never succeed. They are listed in the - * contract suite's `NOT_SDK_CALLABLE` rather than shipped as methods that - * always throw. + * MAILBOX LIFECYCLE is what stays out of reach: creating and deleting a + * mailbox, and minting or revoking an app password, all resolve the acting + * project admin from the session user; an API key carries no user, so those + * routes answer `401` to any `sk_` key however broad its scopes. The contract + * records that — they publish `SessionAuth` without `ApiKeyAuth` — and this SDK + * authenticates only with API keys, so a `create`/`delete` here could never + * succeed. They are listed in the contract suite's `NOT_SDK_CALLABLE` rather + * than shipped as methods that always throw. * - * The three reads below are a different case: their membership check is - * conditional, so a key really can call them. + * Everything below is a different case — the reads' membership check is + * conditional, and {@link sendMessage} / {@link draftMessage} publish + * `ApiKeyAuth` outright — so a key really can call them. */ declare class MailboxesResource { private readonly client; @@ -10441,6 +17655,43 @@ declare class MailboxesResource { * this can identify a credential without being able to reconstruct it. */ listAppPasswords(id: string): Promise; + /** + * 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. + */ + sendMessage(id: string, body: ComposeMailboxMessageRequest): Promise; + /** + * 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 {@link sendMessage} + * 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. + */ + draftMessage(id: string, body: DraftMailboxMessageRequest): Promise; } /** @@ -10502,6 +17753,46 @@ declare class SegmentsResource { listContactsAll(id: string, query?: ListSegmentContactsV1Query): AsyncGenerator; } +/** + * Snippets — 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. + */ +declare class SnippetsResource { + private readonly client; + constructor(client: Sendly); + /** + * Create a snippet. `name` is the literal identifier templates include with + * `{{> name}}` and is unique within the project, so a clash answers 409. + */ + create(body: CreateSnippetRequest): Promise; + /** List snippets with cursor pagination (`limit`/`cursor`) + optional `search` over name and description. */ + list(query?: ListSnippetsQuery): Promise; + /** Fetch a single snippet by id. */ + get(id: string): Promise; + /** Patch an existing snippet. */ + update(id: string, body: UpdateSnippetRequest): Promise; + /** + * Delete a snippet. The API answers 200 with `{ success, data: { id } }`; the + * SDK resolves void. Templates that still include it keep rendering — an + * absent snippet renders as an empty string, like an absent variable. + */ + delete(id: string): Promise; +} + +/** + * The project suppression list — the addresses no send may reach — in both + * dialects. + * + * 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. + */ declare class SuppressionResource { private readonly client; constructor(client: Sendly); @@ -10513,14 +17804,63 @@ declare class SuppressionResource { get(email: string): Promise; /** Remove an email from the suppression list. Returns 204. */ remove(email: string): Promise; + /** + * List suppressed addresses, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold `reason` + * steady for the whole walk — changing it mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + listV1(query?: ListSuppressionsV1Query): Promise; + /** Iterate every suppressed address across pages, yielding one record at a time. */ + listAllV1(query?: ListSuppressionsV1Query): AsyncGenerator; + /** + * 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. + */ + createV1(body: CreateSuppressionV1Request): Promise; + /** + * Retrieve 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. + */ + getV1(email: string): Promise; + /** + * Un-suppress an address: mail can flow to it again. Resolves + * `{ email, deleted }`. + * + * 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. + */ + deleteV1(email: string): Promise; } +/** + * 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. + */ declare class TemplatesResource { private readonly client; constructor(client: Sendly); /** Create a reusable email template. */ create(body: CreateTemplateRequest): Promise; - /** List templates with cursor pagination (`limit`/`cursor`) + optional type filter. */ + /** List templates with cursor pagination (`limit`/`cursor`) + optional `emailCategory` filter. */ list(query?: ListTemplatesQuery): Promise; /** Fetch a single template by id. */ get(id: string): Promise; @@ -10528,6 +17868,114 @@ declare class TemplatesResource { update(id: string, body: UpdateTemplateRequest): Promise; /** Delete a template. The API answers 200 with `{ success, data: { id } }` (409 if still referenced); the SDK resolves void. */ delete(id: string): Promise; + /** + * List templates, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. `search` here + * matches the name only — narrower than the dashboard's search, which also + * reads description and subject. Hold `search` and `email_category` steady + * for the whole walk; changing either mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + listV1(query?: ListTemplatesV1Query): Promise; + /** Iterate every template across pages, yielding one template at a time. */ + listAllV1(query?: ListTemplatesV1Query): AsyncGenerator; + /** + * Create a template. `email_category` defaults to `MARKETING` server-side. + * + * 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. + */ + createV1(body: CreateTemplateV1Request): Promise; + /** Retrieve a single template. */ + getV1(id: string): Promise; + /** + * Patch a template. Only the fields you send are changed. + * + * 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. + */ + updateV1(id: string, body: UpdateTemplateV1Request): Promise; + /** + * Delete a template. Resolves `{ id, deleted }` — the legacy `delete` above + * discards that body, 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. + */ + deleteV1(id: string): Promise; +} + +/** + * Topics on the `/api/v1` surface — 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. + */ +declare class TopicsResource { + private readonly client; + constructor(client: Sendly); + /** + * List topics, newest first. + * + * Archived topics are omitted unless `include_archived` asks for them. There + * is no delete — archiving is the retire button, because a topic is where + * people's answers are recorded. {@link listAll} drives the loop for you. + * + * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + */ + list(query?: ListTopicsV1Query): Promise; + /** + * Iterate every topic across pages, yielding one topic at a time. + * + * The walk is written out here rather than delegated to `paginateCursor` + * because this endpoint names its cursor `cursor` on both sides — the query + * parameter and the response field — where every other v1 list takes `after` + * and answers `next_cursor`. + */ + listAll(query?: ListTopicsV1Query): AsyncGenerator; + /** + * Create a topic. + * + * `key` is the stable name every preference form and integration refers to, + * so it survives a rename of `name` and cannot be changed afterwards. + * + * `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. + */ + create(body: CreateTopicV1Request): Promise; + /** Retrieve a single topic. */ + get(id: string): Promise; + /** + * Patch a topic. Only the fields you send are changed. + * + * `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. + */ + update(id: string, body: UpdateTopicV1Request): Promise; + /** + * Record what one contact wants on one topic. The two directions are not + * symmetric, on purpose. + * + * `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. + */ + setSubscription(id: string, body: SetTopicSubscriptionV1Request): Promise; } /** @@ -10548,6 +17996,65 @@ declare class UsageResource { get(): Promise; } +/** + * Email validation on the `/api/v1` surface — check addresses before you mail + * them, and read back what a bulk run found. + * + * Responses are bare v1 bodies (no `{ success, data }` envelope) and errors are + * RFC 9457 problem documents. + */ +declare class ValidationResource { + private readonly client; + constructor(client: Sendly); + /** + * 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 + * (`lists.startValidationRun`) 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. + */ + validateEmails(body: ValidateEmailsV1Request): Promise; + /** + * Retrieve a bulk validation run: how far it has got, and what it found. + * + * The other way a run starts is `lists.startValidationRun`, 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. + */ + getRun(id: string): Promise; + /** + * 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. + * + * This list pages on `cursor`, not the `after` every other v1 collection + * takes, and its envelope carries the next page under `cursor` rather than + * `next_cursor`. {@link listResultsAll} drives that loop for you. + */ + listResults(id: string, query?: ListValidationResultsV1Query): Promise; + /** + * Iterate every result across pages, yielding one address's verdict at a time. + * + * Hand-rolled rather than routed through `paginateCursor`: the shared helper + * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on + * both sides, so the helper would send an ignored parameter and re-fetch page + * one forever. Stops on `has_more: false`, a null cursor, or a cursor the + * server repeats. + */ + listResultsAll(id: string, query?: ListValidationResultsV1Query): AsyncGenerator; +} + declare class VerifyResource { private readonly client; constructor(client: Sendly); @@ -10563,6 +18070,15 @@ type ListWebhookCallsQuery = { limit?: number; cursor?: string; }; +/** + * Webhook endpoints, 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. + */ declare class WebhooksResource { private readonly client; constructor(client: Sendly); @@ -10584,6 +18100,64 @@ declare class WebhooksResource { rotateSecret(id: string): Promise; /** List recent delivery attempts for a webhook. */ listCalls(id: string, query?: ListWebhookCallsQuery): Promise; + /** + * List webhook endpoints, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. + * {@link listAllV1} drives the loop for you. Signing secrets are not on this + * response — see {@link rotateSecretV1} if you have lost one. + */ + listV1(query?: ListWebhooksV1Query): Promise; + /** Iterate every webhook endpoint across pages, yielding one endpoint at a time. */ + listAllV1(query?: ListWebhooksV1Query): AsyncGenerator; + /** + * Register an endpoint to receive HMAC-signed deliveries for the events named + * in `event_types`. + * + * Resolves `{ webhook, secret }`, and this is one of only two calls that ever + * carry the signing secret — {@link rotateSecretV1} 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 + * `verifySignature` to authenticate the deliveries that arrive at your + * endpoint. + */ + createV1(body: CreateWebhookV1Request): Promise; + /** Retrieve a single webhook endpoint. The signing secret is not on this response. */ + getV1(id: string): Promise; + /** + * 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. + */ + updateV1(id: string, body: UpdateWebhookV1Request): Promise; + /** + * 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. Resolves `{ id, deleted }`. Deliveries already in flight are not + * recalled, so the endpoint may still receive an event shortly after this. + */ + deleteV1(id: string): Promise; + /** + * Mint a fresh signing secret for an endpoint. + * + * The new plaintext is returned exactly once, here — this and + * {@link createV1} 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 + * `verifySignature`. 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. + */ + rotateSecretV1(id: string): Promise; } /** @@ -10642,10 +18216,73 @@ declare class WorkflowsResource { * `{ from }`; there is no 90-day ceiling here, unlike `analytics.*`. */ stats(id: string, query?: WorkflowStatsV1Query): Promise; + /** + * Every step in the workflow — including its `TRIGGER` entry node — 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 {@link replaceGraph} — read, edit one step, + * send it back. + */ + getGraph(id: string): Promise; + /** + * 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. {@link pause} first. + */ + replaceGraph(id: string, body: ReplaceWorkflowGraphV1Request): Promise; + /** + * 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 `. + */ + clone(id: string, body: CloneWorkflowV1Request): Promise; + /** + * Disable the workflow *and cancel every `RUNNING`/`WAITING` execution in it*, + * resolving `{ 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: {@link resume} re-opens the workflow to new + * runs, it does not put the cancelled contacts back where they were. + */ + pause(id: string): Promise; + /** + * 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. + */ + resume(id: string): Promise; } /** Build-time package version (kept in sync with package.json). */ -declare const SDK_VERSION = "1.0.0"; +declare const SDK_VERSION = "1.1.0"; /** Default production API base. Override via `baseUrl` for staging or self-hosted deployments. */ declare const DEFAULT_BASE_URL = "https://api.sendly.now"; interface SendlyClientOptions { @@ -10663,8 +18300,14 @@ interface SendlyClientOptions { interface RequestOptions { /** Path relative to baseUrl, must start with `/`. */ path: string; - /** HTTP method. */ - method: "GET" | "POST" | "PATCH" | "DELETE"; + /** + * HTTP method. + * + * `PUT` exists for exactly one operation — replacing a workflow graph — and the + * distinction is the point: a graph is replaced whole, never patched, because a + * partial edit to a node list has no meaning without the edges that reference it. + */ + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; /** Optional JSON body. Will be serialized + Content-Type set. */ body?: unknown; /** @@ -10699,6 +18342,8 @@ declare class Sendly { readonly events: EventsResource; readonly verify: VerifyResource; readonly lists: ListsResource; + /** Reusable body fragments a template includes with `{{> name}}`. */ + readonly snippets: SnippetsResource; /** Receiving mailboxes. Reads only — the writes need a user, not an API key. */ readonly mailboxes: MailboxesResource; /** Campaigns on the versioned `/api/v1` surface. */ @@ -10713,6 +18358,12 @@ declare class Sendly { readonly usage: UsageResource; /** The project this key belongs to, on the versioned `/api/v1` surface. */ readonly projects: ProjectsResource; + /** Consent topics and what each contact has said they want. */ + readonly topics: TopicsResource; + /** Address validation — one batch, or a whole list. */ + readonly validation: ValidationResource; + /** Why mail from your domains is or is not arriving. */ + readonly deliverability: DeliverabilityResource; private readonly apiKey; private readonly baseUrl; private readonly fetchImpl; @@ -10940,4 +18591,4 @@ declare function verifySignature(payload: string | Buffer, signature: string, ti */ declare function constructEvent>(payload: string | Buffer, signature: string, timestamp: string, secret: string, options?: VerifySignatureOptions): T; -export { type AddDomainRequest, type AddSuppressionRequest, type AnalyticsCampaignStatsV1, type AnalyticsCampaignsV1Query, AnalyticsResource, type AnalyticsTimeseriesV1, type AnalyticsTimeseriesV1Query, type AnalyticsTopCampaignsV1, type AnalyticsWindowV1, type AppPasswordRecord, type BatchEntryResult, type BatchSendRequest, type BatchSendResponse, type BulkCreateContactsRequest, type BulkDeleteContactsRequest, type CampaignDeletedV1, type CampaignListV1, type CampaignStatsV1, type CampaignV1, CampaignsResource, type ContactListResponse, type ContactRecord, ContactsResource, type CreateCampaignV1Request, type CreateContactRequest, type CreateSegmentV1Request, type CreateTemplateRequest, type CreateWebhookRequest, type CreateWorkflowV1Request, type CursorPage, type CursorPageQuery, DEFAULT_BASE_URL, DEFAULT_TOLERANCE_MS, type DomainListResponse, type DomainRecord, type DomainSetupSession, type DomainVerificationStatus, DomainsResource, type EmailGetResponse, type EmailListResponse, type EmailRecord, type EmailTestV1, type EmailV1, EmailsResource, type ErrorEnvelope, type EventListV1, type EventNamesV1, type EventStatsV1, type EventStatsV1Query, type EventV1, EventsResource, type IdResponse, type IdempotencyOptions, type ListCampaignsV1Query, type ListContactsQuery, type ListEmailsQuery, type ListEventsV1Query, type ListSegmentContactsV1Query, type ListSegmentsV1Query, type ListSubscribeData, type ListSubscribeRequest, type ListSubscribeResponse, type ListSuppressionsQuery, type ListTemplatesQuery, type ListTopCampaignsV1Query, type ListUnsubscribeData, type ListUnsubscribeRequest, type ListUnsubscribeResponse, type ListWebhookCallsQuery, type ListWorkflowExecutionsV1Query, type ListWorkflowsV1Query, ListsResource, type MailboxDetail, type MailboxRecord, MailboxesResource, type Problem, type ProblemDocument, type ProblemFieldError, type ProjectV1, ProjectsResource, type RecordEventV1Request, type RequestOptions, SDK_VERSION, type SegmentContactListV1, type SegmentContactV1, type SegmentDeletedV1, type SegmentListV1, type SegmentV1, SegmentsResource, type SendCampaignV1Request, type SendEmailData, type SendEmailRequest, type SendEmailResponse, type SendEmailV1Request, type SendTestEmailV1Request, Sendly, SendlyAuthenticationError, type SendlyClientOptions, SendlyConflictError, SendlyConnectionError, SendlyError, SendlyNotFoundError, SendlyPermissionError, SendlyRateLimitError, SendlyServerError, SendlyValidationError, type StartWorkflowExecutionV1Request, type SuccessEmpty, type SuppressionCheckResponse, type SuppressionListResponse, type SuppressionRecord, SuppressionResource, type TemplateListResponse, type TemplateRecord, TemplatesResource, type TrackEventData, type TrackEventRequest, type TrackEventResponse, type UpdateCampaignV1Request, type UpdateContactRequest, type UpdateSegmentV1Request, type UpdateTemplateRequest, type UpdateWebhookRequest, type UpdateWorkflowV1Request, UsageResource, type UsageV1, type VerifyEmailData, type VerifyEmailRequest, type VerifyEmailResponse, VerifyResource, type VerifySignatureOptions, type WebhookCall, type WebhookCallsListResponse, type WebhookCreateResponse, type WebhookGetResponse, type WebhookListResponse, type WebhookRecord, type WebhookRotateSecretResponse, WebhooksResource, type WorkflowDeletedV1, type WorkflowExecutionListV1, type WorkflowExecutionV1, type WorkflowListV1, type WorkflowStatsV1, type WorkflowStatsV1Query, type WorkflowV1, WorkflowsResource, asProblemDocument, type components, constructEvent, type operations, paginateCursor, type paths, verifySignature }; +export { type AddDomainRequest, type AddSuppressionRequest, type AnalyticsCampaignStatsV1, type AnalyticsCampaignsV1Query, AnalyticsResource, type AnalyticsTimeseriesV1, type AnalyticsTimeseriesV1Query, type AnalyticsTopCampaignsV1, type AnalyticsWindowV1, type AppPasswordRecord, type AssignDomainStreamRequest, type BatchEntryResult, type BatchSendRequest, type BatchSendResponse, type BulkCreateContactsRequest, type BulkDeleteContactsRequest, type CampaignDeletedV1, type CampaignFailureListV1, type CampaignFailureV1, type CampaignListV1, type CampaignRetryFailedV1, type CampaignStatsV1, type CampaignV1, CampaignsResource, type CloneWorkflowV1Request, type ComposeMailboxMessageRequest, type ContactDeletedV1, type ContactListResponse, type ContactListV1, type ContactRecord, type ContactTopicPreferencesV1, type ContactV1, ContactsResource, type CreateCampaignV1Request, type CreateContactRequest, type CreateContactV1Request, type CreateDomainV1Request, type CreateListV1Request, type CreateSegmentV1Request, type CreateSnippetRequest, type CreateSuppressionV1Request, type CreateTemplateRequest, type CreateTemplateV1Request, type CreateTopicV1Request, type CreateWebhookRequest, type CreateWebhookV1Request, type CreateWorkflowV1Request, type CursorPage, type CursorPageQuery, DEFAULT_BASE_URL, DEFAULT_TOLERANCE_MS, type DeliverabilityDiagnosisV1, type DeliverabilityFindingSeverityV1, type DeliverabilityFindingV1, type DeliverabilityIdentityV1, type DeliverabilityRecentDeliveryV1, DeliverabilityResource, type DeliverabilitySuppressionV1, type DiagnoseDeliverabilityV1Query, type DmarcReportListV1, type DmarcReportV1, type DomainDeletedV1, type DomainListResponse, type DomainListV1, type DomainRecord, type DomainSetupSession, type DomainV1, type DomainVerificationStatus, DomainsResource, type DraftMailboxMessageRequest, type EmailDetailResponse, type EmailEvent, type EmailListResponse, type EmailRecord, type EmailResponse, type EmailTestV1, type EmailV1, type EmailValidationBatchV1, type EmailValidationResultListV1, type EmailValidationResultV1, type EmailValidationRunV1, type EmailValidationV1, type EmailValidationVerdictV1, type EmailWithEvents, EmailsResource, type ErrorEnvelope, type EventListV1, type EventNamesV1, type EventStatsV1, type EventStatsV1Query, type EventV1, EventsResource, type IdResponse, type IdempotencyOptions, type ListCampaignFailuresV1Query, type ListCampaignsV1Query, type ListContactsQuery, type ListContactsV1Query, type ListDeletedV1, type ListDmarcReportsV1Query, type ListDomainsV1Query, type ListEmailsQuery, type ListEventsV1Query, type ListListV1, type ListListsV1Query, type ListRecipientDomainStatsV1Query, type ListSegmentContactsV1Query, type ListSegmentsV1Query, type ListSnippetsQuery, type ListSubscribeData, type ListSubscribeRequest, type ListSubscribeResponse, type ListSuppressionsQuery, type ListSuppressionsV1Query, type ListTemplatesQuery, type ListTemplatesV1Query, type ListTopCampaignsV1Query, type ListTopicsV1Query, type ListUnsubscribeData, type ListUnsubscribeRequest, type ListUnsubscribeResponse, type ListV1, type ListValidationResultsV1Query, type ListWebhookCallsQuery, type ListWebhooksV1Query, type ListWorkflowExecutionsV1Query, type ListWorkflowsV1Query, ListsResource, type MailboxDetail, type MailboxRecord, MailboxesResource, type Problem, type ProblemDocument, type ProblemFieldError, type ProjectV1, ProjectsResource, type RecipientDomainStatsListV1, type RecipientDomainStatsV1, type RecordEventV1Request, type ReplaceWorkflowGraphV1Request, type RequestOptions, SDK_VERSION, type SegmentContactListV1, type SegmentContactV1, type SegmentDeletedV1, type SegmentListV1, type SegmentV1, SegmentsResource, type SendCampaignV1Request, type SendEmailData, type SendEmailRequest, type SendEmailResponse, type SendEmailV1Request, type SendTestEmailV1Request, Sendly, SendlyAuthenticationError, type SendlyClientOptions, SendlyConflictError, SendlyConnectionError, SendlyError, SendlyNotFoundError, SendlyPermissionError, SendlyRateLimitError, SendlyServerError, SendlyValidationError, type SetTopicSubscriptionV1Request, type SnippetListResponse, type SnippetRecord, SnippetsResource, type StartWorkflowExecutionV1Request, type SuccessEmpty, type SuppressionCheckResponse, type SuppressionDeletedV1, type SuppressionListResponse, type SuppressionListV1, type SuppressionRecord, SuppressionResource, type SuppressionV1, type TemplateDeletedV1, type TemplateListResponse, type TemplateListV1, type TemplateRecord, type TemplateV1, TemplatesResource, type TopicListV1, type TopicSubscriptionStatusV1, type TopicSubscriptionV1, type TopicV1, TopicsResource, type TrackEventData, type TrackEventRequest, type TrackEventResponse, type UpdateCampaignV1Request, type UpdateContactRequest, type UpdateContactV1Request, type UpdateListV1Request, type UpdateSegmentV1Request, type UpdateSnippetRequest, type UpdateTemplateRequest, type UpdateTemplateV1Request, type UpdateTopicV1Request, type UpdateWebhookRequest, type UpdateWebhookV1Request, type UpdateWorkflowV1Request, UsageResource, type UsageV1, type ValidateEmailsV1Request, ValidationResource, type VerifyEmailData, type VerifyEmailRequest, type VerifyEmailResponse, VerifyResource, type VerifySignatureOptions, type WebhookCall, type WebhookCallsListResponse, type WebhookCreateResponse, type WebhookCreatedV1, type WebhookDeletedV1, type WebhookGetResponse, type WebhookListResponse, type WebhookListV1, type WebhookRecord, type WebhookRotateSecretResponse, type WebhookSecretRotatedV1, type WebhookV1, WebhooksResource, type WorkflowDeletedV1, type WorkflowExecutionListV1, type WorkflowExecutionV1, type WorkflowGraphV1, type WorkflowListV1, type WorkflowStateChangeV1, type WorkflowStatsV1, type WorkflowStatsV1Query, type WorkflowV1, WorkflowsResource, asProblemDocument, type components, constructEvent, type operations, paginateCursor, type paths, verifySignature }; diff --git a/dist/index.js b/dist/index.js index 920131b..195e500 100644 --- a/dist/index.js +++ b/dist/index.js @@ -153,6 +153,45 @@ var CampaignsResource = class { path: `/api/v1/campaigns/${encodeURIComponent(id)}/stats` }); } + /** + * The recipients this campaign did not reach, and why. + * + * {@link 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 it is `null` on rows recorded before + * reasons were captured. + * + * Cursor-paginated like every other v1 list, but uniquely it also carries + * `total`: {@link retryFailed} acts on that number, and `has_more` alone + * cannot tell you whether 3 or 30,000 sends failed. + */ + async listFailures(id, query) { + return this.client.request({ + method: "GET", + path: `/api/v1/campaigns/${encodeURIComponent(id)}/failures`, + query + }); + } + /** Iterate every failed send across pages, yielding one recipient at a time. */ + async *listFailuresAll(id, query) { + yield* paginateCursor((after) => this.listFailures(id, { ...query, after }), query?.after); + } + /** + * 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, not re-sent. + * + * The walk runs in the background, so this resolves 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. + */ + async retryFailed(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/campaigns/${encodeURIComponent(id)}/retry-failed` + }); + } }; // src/resources/contacts.ts @@ -231,6 +270,174 @@ var ContactsResource = class { noContent: true }); } + /** + * List contacts on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after` with no total count, narrowed by + * `search` (case-insensitive substring on the address) and `subscribed`. + * Hold the filters steady for the whole walk — the cursor encodes them, and + * changing one mid-pagination returns `422 validation_error` asking you to + * restart. {@link listAllV1} drives the loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/contacts", + query + }); + } + /** Iterate every v1 contact across pages, yielding one contact at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Create a contact. Only `email` is required — `subscribed` defaults to true + * server-side, and `custom_fields` is arbitrary JSON that templates can read + * back as `{{ variables }}`. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/contacts", + body + }); + } + /** + * Retrieve a single contact by id. v1 has no lookup-by-address route — reach + * a contact you only know the email of through {@link listV1}'s `search`. + */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/contacts/${encodeURIComponent(id)}` + }); + } + /** + * Patch a contact. Only the fields you send are changed, with two caveats. + * + * `email` is not patchable at all: an address is the contact's identity here, + * 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. Sending a partial object silently drops the rest. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/contacts/${encodeURIComponent(id)}`, + body + }); + } + /** + * Delete a contact. Unlike the legacy {@link delete}, this resolves the + * `{ id, deleted }` acknowledgement rather than discarding it. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/contacts/${encodeURIComponent(id)}` + }); + } + /** + * 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 nothing 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. + */ + async topicPreferences(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/contacts/${encodeURIComponent(id)}/topics` + }); + } +}; + +// src/resources/deliverability.ts +var DeliverabilityResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * Diagnose one of your SENDING domains: 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. + * + * `query.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. + */ + async diagnose(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/diagnose", + query + }); + } + /** + * Delivery outcomes broken out 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 {@link 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. + */ + async listDomainStats(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/domains", + query + }); + } + /** Iterate every recipient-domain row across pages, one day-and-domain at a time. */ + async *listDomainStatsAll(query) { + yield* paginateCursor((after) => this.listDomainStats({ ...query, after }), query?.after); + } + /** + * DMARC aggregate (RUA) reports that receiving providers have sent about your + * domains, newest reporting 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). + * + * `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. + */ + async listDmarcReports(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/dmarc", + query + }); + } + /** Iterate every DMARC report across pages, one report at a time. */ + async *listDmarcReportsAll(query) { + yield* paginateCursor((after) => this.listDmarcReports({ ...query, after }), query?.after); + } }; // src/resources/domains.ts @@ -303,6 +510,29 @@ var DomainsResource = class { }); return this.client.unwrap(envelope); } + /** + * 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: null` 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. + */ + async assignStream(id, body) { + const envelope = await this.client.request({ + method: "PATCH", + path: `/api/domains/${encodeURIComponent(id)}`, + body + }); + return this.client.unwrap(envelope); + } /** Delete a domain. */ async delete(id) { await this.client.request({ @@ -310,6 +540,88 @@ var DomainsResource = class { path: `/api/domains/${encodeURIComponent(id)}` }); } + /** + * List sending domains, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. {@link listAllV1} + * drives the loop 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. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/domains", + query + }); + } + /** Iterate every sending domain across pages, yielding one domain at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * 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 {@link verifyV1} 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. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/domains", + body + }); + } + /** Retrieve a single sending domain. */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/domains/${encodeURIComponent(id)}` + }); + } + /** + * Re-read the domain's state from SES and DNS, and resolve the refreshed + * document. + * + * 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. + */ + async verifyV1(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/domains/${encodeURIComponent(id)}/verify` + }); + } + /** + * Remove a sending domain. Resolves `{ id, deleted }`. + * + * 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. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/domains/${encodeURIComponent(id)}` + }); + } }; // src/resources/emails.ts @@ -392,14 +704,28 @@ var EmailsResource = class { query }); } - /** Fetch a single email and its delivery events. */ + /** + * 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 along with it. + */ async get(id) { return this.client.request({ method: "GET", path: `/api/emails/${encodeURIComponent(id)}` }); } - /** Cancel a scheduled (PENDING) email before it fires. */ + /** + * Cancel a scheduled (PENDING) email before it fires. + * + * Resolves the email itself, not an empty acknowledgement: the contract has + * always published `EmailResponse` here, and the caller wants the row's new + * status more than it wants a `{ success: true }` it already inferred from the + * absence of an exception. + */ async cancelSchedule(id) { return this.client.request({ method: "DELETE", @@ -501,8 +827,8 @@ var ListsResource = class { * **Double opt-in.** When the list has `doubleOptIn` enabled the membership * is created as `PENDING` and the result carries a `confirmToken`. Sendly * does **not** send the confirmation email — your application must deliver - * `/api/lists/confirm?token=` to the contact itself. The token - * is valid for 24 hours. + * `/api/lists/confirm-subscription?token=` to the contact + * itself. The token is valid for 24 hours. * * **Re-subscribing after an opt-out.** If the email already holds an * `UNSUBSCRIBED` membership on this list, the call fails with @@ -532,6 +858,83 @@ var ListsResource = class { }); return this.client.unwrap(envelope); } + /** + * List the project's subscriber lists on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold the + * arguments steady for the whole walk — changing them mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/lists", + query + }); + } + /** Iterate every list across pages, yielding one list at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Create a list. Only `name` is required; `double_opt_in` defaults to false. + * + * Turning double opt-in on does not make Sendly send anything — it only + * changes {@link subscribe} to create the membership as `PENDING` and hand + * back the `confirmToken` your application delivers. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/lists", + body + }); + } + /** + * Retrieve 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. + */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/lists/${encodeURIComponent(id)}` + }); + } + /** + * 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. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/lists/${encodeURIComponent(id)}`, + body + }); + } + /** Delete a list. Resolves `{ id, deleted }`. Removes the list, not its contacts. */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/lists/${encodeURIComponent(id)}` + }); + } + /** + * 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.getRun`. + */ + async startValidationRun(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/lists/${encodeURIComponent(id)}/validation-runs` + }); + } }; // src/resources/mailboxes.ts @@ -589,6 +992,57 @@ var MailboxesResource = class { }); return this.client.unwrap(envelope); } + /** + * 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. + */ + async sendMessage(id, body) { + const envelope = await this.client.request({ + method: "POST", + path: `/api/mailboxes/${encodeURIComponent(id)}/messages`, + body + }); + return this.client.unwrap(envelope); + } + /** + * 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 {@link sendMessage} + * 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. + */ + async draftMessage(id, body) { + const envelope = await this.client.request({ + method: "POST", + path: `/api/mailboxes/${encodeURIComponent(id)}/drafts`, + body + }); + return this.client.unwrap(envelope); + } }; // src/resources/projects.ts @@ -684,6 +1138,63 @@ var SegmentsResource = class { } }; +// src/resources/snippets.ts +var SnippetsResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * Create a snippet. `name` is the literal identifier templates include with + * `{{> name}}` and is unique within the project, so a clash answers 409. + */ + async create(body) { + const envelope = await this.client.request({ + method: "POST", + path: "/api/snippets", + body + }); + return this.client.unwrap(envelope); + } + /** List snippets with cursor pagination (`limit`/`cursor`) + optional `search` over name and description. */ + async list(query) { + return this.client.request({ + method: "GET", + path: "/api/snippets", + query + }); + } + /** Fetch a single snippet by id. */ + async get(id) { + const envelope = await this.client.request({ + method: "GET", + path: `/api/snippets/${encodeURIComponent(id)}` + }); + return this.client.unwrap(envelope); + } + /** Patch an existing snippet. */ + async update(id, body) { + const envelope = await this.client.request({ + method: "PATCH", + path: `/api/snippets/${encodeURIComponent(id)}`, + body + }); + return this.client.unwrap(envelope); + } + /** + * Delete a snippet. The API answers 200 with `{ success, data: { id } }`; the + * SDK resolves void. Templates that still include it keep rendering — an + * absent snippet renders as an empty string, like an absent variable. + */ + async delete(id) { + await this.client.request({ + method: "DELETE", + path: `/api/snippets/${encodeURIComponent(id)}`, + noContent: true + }); + } +}; + // src/resources/suppression.ts var SuppressionResource = class { constructor(client) { @@ -722,6 +1233,70 @@ var SuppressionResource = class { noContent: true }); } + /** + * List suppressed addresses, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold `reason` + * steady for the whole walk — changing it mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/suppressions", + query + }); + } + /** Iterate every suppressed address across pages, yielding one record at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * 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. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/suppressions", + body + }); + } + /** + * Retrieve 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. + */ + async getV1(email) { + return this.client.request({ + method: "GET", + path: `/api/v1/suppressions/${encodeURIComponent(email)}` + }); + } + /** + * Un-suppress an address: mail can flow to it again. Resolves + * `{ email, deleted }`. + * + * 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. + */ + async deleteV1(email) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/suppressions/${encodeURIComponent(email)}` + }); + } }; // src/resources/templates.ts @@ -739,7 +1314,7 @@ var TemplatesResource = class { }); return this.client.unwrap(envelope); } - /** List templates with cursor pagination (`limit`/`cursor`) + optional type filter. */ + /** List templates with cursor pagination (`limit`/`cursor`) + optional `emailCategory` filter. */ async list(query) { return this.client.request({ method: "GET", @@ -772,6 +1347,179 @@ var TemplatesResource = class { noContent: true }); } + /** + * List templates, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. `search` here + * matches the name only — narrower than the dashboard's search, which also + * reads description and subject. Hold `search` and `email_category` steady + * for the whole walk; changing either mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/templates", + query + }); + } + /** Iterate every template across pages, yielding one template at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Create a template. `email_category` defaults to `MARKETING` server-side. + * + * 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. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/templates", + body + }); + } + /** Retrieve a single template. */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/templates/${encodeURIComponent(id)}` + }); + } + /** + * Patch a template. Only the fields you send are changed. + * + * 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. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/templates/${encodeURIComponent(id)}`, + body + }); + } + /** + * Delete a template. Resolves `{ id, deleted }` — the legacy `delete` above + * discards that body, 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. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/templates/${encodeURIComponent(id)}` + }); + } +}; + +// src/resources/topics.ts +var TopicsResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * List topics, newest first. + * + * Archived topics are omitted unless `include_archived` asks for them. There + * is no delete — archiving is the retire button, because a topic is where + * people's answers are recorded. {@link listAll} drives the loop for you. + * + * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + */ + async list(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/topics", + query + }); + } + /** + * Iterate every topic across pages, yielding one topic at a time. + * + * The walk is written out here rather than delegated to `paginateCursor` + * because this endpoint names its cursor `cursor` on both sides — the query + * parameter and the response field — where every other v1 list takes `after` + * and answers `next_cursor`. + */ + async *listAll(query) { + let cursor = query?.cursor; + for (; ; ) { + const page = await this.list({ ...query, cursor }); + for (const topic of page.data) { + yield topic; + } + const next = page.cursor; + if (!page.has_more || next === null || next === cursor) return; + cursor = next; + } + } + /** + * Create a topic. + * + * `key` is the stable name every preference form and integration refers to, + * so it survives a rename of `name` and cannot be changed afterwards. + * + * `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. + */ + async create(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/topics", + body + }); + } + /** Retrieve a single topic. */ + async get(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/topics/${encodeURIComponent(id)}` + }); + } + /** + * Patch a topic. Only the fields you send are changed. + * + * `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. + */ + async update(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/topics/${encodeURIComponent(id)}`, + body + }); + } + /** + * Record what one contact wants on one topic. The two directions are not + * symmetric, on purpose. + * + * `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. + */ + async setSubscription(id, body) { + return this.client.request({ + method: "POST", + path: `/api/v1/topics/${encodeURIComponent(id)}/subscriptions`, + body + }); + } }; // src/resources/usage.ts @@ -796,6 +1544,89 @@ var UsageResource = class { } }; +// src/resources/validation.ts +var ValidationResource = class { + constructor(client) { + this.client = client; + } + client; + /** + * 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 + * (`lists.startValidationRun`) 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. + */ + async validateEmails(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/email-validations", + body + }); + } + /** + * Retrieve a bulk validation run: how far it has got, and what it found. + * + * The other way a run starts is `lists.startValidationRun`, 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. + */ + async getRun(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/validation-runs/${encodeURIComponent(id)}` + }); + } + /** + * 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. + * + * This list pages on `cursor`, not the `after` every other v1 collection + * takes, and its envelope carries the next page under `cursor` rather than + * `next_cursor`. {@link listResultsAll} drives that loop for you. + */ + async listResults(id, query) { + return this.client.request({ + method: "GET", + path: `/api/v1/validation-runs/${encodeURIComponent(id)}/results`, + query + }); + } + /** + * Iterate every result across pages, yielding one address's verdict at a time. + * + * Hand-rolled rather than routed through `paginateCursor`: the shared helper + * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on + * both sides, so the helper would send an ignored parameter and re-fetch page + * one forever. Stops on `has_more: false`, a null cursor, or a cursor the + * server repeats. + */ + async *listResultsAll(id, query) { + let cursor = query?.cursor; + for (; ; ) { + const page = await this.listResults(id, { ...query, cursor }); + for (const result of page.data ?? []) { + yield result; + } + const next = page.cursor; + if (!page.has_more || next === null || next === void 0 || next === cursor) return; + cursor = next; + } + } +}; + // src/resources/verify.ts var VerifyResource = class { constructor(client) { @@ -880,6 +1711,99 @@ var WebhooksResource = class { query }); } + /** + * List webhook endpoints, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. + * {@link listAllV1} drives the loop for you. Signing secrets are not on this + * response — see {@link rotateSecretV1} if you have lost one. + */ + async listV1(query) { + return this.client.request({ + method: "GET", + path: "/api/v1/webhooks", + query + }); + } + /** Iterate every webhook endpoint across pages, yielding one endpoint at a time. */ + async *listAllV1(query) { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + /** + * Register an endpoint to receive HMAC-signed deliveries for the events named + * in `event_types`. + * + * Resolves `{ webhook, secret }`, and this is one of only two calls that ever + * carry the signing secret — {@link rotateSecretV1} 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 + * `verifySignature` to authenticate the deliveries that arrive at your + * endpoint. + */ + async createV1(body) { + return this.client.request({ + method: "POST", + path: "/api/v1/webhooks", + body + }); + } + /** Retrieve a single webhook endpoint. The signing secret is not on this response. */ + async getV1(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/webhooks/${encodeURIComponent(id)}` + }); + } + /** + * 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. + */ + async updateV1(id, body) { + return this.client.request({ + method: "PATCH", + path: `/api/v1/webhooks/${encodeURIComponent(id)}`, + body + }); + } + /** + * 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. Resolves `{ id, deleted }`. Deliveries already in flight are not + * recalled, so the endpoint may still receive an event shortly after this. + */ + async deleteV1(id) { + return this.client.request({ + method: "DELETE", + path: `/api/v1/webhooks/${encodeURIComponent(id)}` + }); + } + /** + * Mint a fresh signing secret for an endpoint. + * + * The new plaintext is returned exactly once, here — this and + * {@link createV1} 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 + * `verifySignature`. 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. + */ + async rotateSecretV1(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/webhooks/${encodeURIComponent(id)}/rotate-secret` + }); + } }; // src/resources/workflows.ts @@ -989,6 +1913,96 @@ var WorkflowsResource = class { query }); } + /** + * Every step in the workflow — including its `TRIGGER` entry node — 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 {@link replaceGraph} — read, edit one step, + * send it back. + */ + async getGraph(id) { + return this.client.request({ + method: "GET", + path: `/api/v1/workflows/${encodeURIComponent(id)}/graph` + }); + } + /** + * 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. {@link pause} first. + */ + async replaceGraph(id, body) { + return this.client.request({ + method: "PUT", + path: `/api/v1/workflows/${encodeURIComponent(id)}/graph`, + body + }); + } + /** + * 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 `. + */ + async clone(id, body) { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/clone`, + body + }); + } + /** + * Disable the workflow *and cancel every `RUNNING`/`WAITING` execution in it*, + * resolving `{ 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: {@link resume} re-opens the workflow to new + * runs, it does not put the cancelled contacts back where they were. + */ + async pause(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/pause` + }); + } + /** + * 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. + */ + async resume(id) { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/resume` + }); + } }; // src/errors.ts @@ -1106,7 +2120,7 @@ function errorFromResponse(statusCode, errorCode, message, body, contentType) { } // src/client.ts -var SDK_VERSION = "1.0.0"; +var SDK_VERSION = "1.1.0"; var DEFAULT_BASE_URL = "https://api.sendly.now"; var Sendly = class { emails; @@ -1118,6 +2132,8 @@ var Sendly = class { events; verify; lists; + /** Reusable body fragments a template includes with `{{> name}}`. */ + snippets; /** Receiving mailboxes. Reads only — the writes need a user, not an API key. */ mailboxes; /** Campaigns on the versioned `/api/v1` surface. */ @@ -1132,6 +2148,12 @@ var Sendly = class { usage; /** The project this key belongs to, on the versioned `/api/v1` surface. */ projects; + /** Consent topics and what each contact has said they want. */ + topics; + /** Address validation — one batch, or a whole list. */ + validation; + /** Why mail from your domains is or is not arriving. */ + deliverability; apiKey; baseUrl; fetchImpl; @@ -1162,6 +2184,7 @@ var Sendly = class { this.events = new EventsResource(this); this.verify = new VerifyResource(this); this.lists = new ListsResource(this); + this.snippets = new SnippetsResource(this); this.mailboxes = new MailboxesResource(this); this.campaigns = new CampaignsResource(this); this.segments = new SegmentsResource(this); @@ -1169,6 +2192,9 @@ var Sendly = class { this.analytics = new AnalyticsResource(this); this.usage = new UsageResource(this); this.projects = new ProjectsResource(this); + this.topics = new TopicsResource(this); + this.validation = new ValidationResource(this); + this.deliverability = new DeliverabilityResource(this); } /** * Low-level request helper. Resources call this; consumers can call it @@ -1327,6 +2353,7 @@ export { ContactsResource, DEFAULT_BASE_URL, DEFAULT_TOLERANCE_MS, + DeliverabilityResource, DomainsResource, EmailsResource, EventsResource, @@ -1345,9 +2372,12 @@ export { SendlyRateLimitError, SendlyServerError, SendlyValidationError, + SnippetsResource, SuppressionResource, TemplatesResource, + TopicsResource, UsageResource, + ValidationResource, VerifyResource, WebhooksResource, WorkflowsResource, diff --git a/openapi.json b/openapi.json index dff971c..0f3a394 100644 --- a/openapi.json +++ b/openapi.json @@ -1,1914 +1,1963 @@ { + "openapi": "3.1.0", + "info": { + "title": "Sendly API", + "version": "1.0.0", + "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.", + "contact": { + "name": "Sendly Support", + "url": "https://sendly.now" + }, + "license": { + "name": "AGPL-3.0", + "url": "https://www.gnu.org/licenses/agpl-3.0.txt" + } + }, + "servers": [ + { + "url": "https://api.sendly.now" + } + ], + "tags": [ + { + "name": "Emails", + "description": "Send transactional email and inspect deliveries." + }, + { + "name": "Campaigns", + "description": "Bulk sends to a list, segment, or filtered audience. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." + }, + { + "name": "Segments", + "description": "Saved audiences — a `DYNAMIC` filter re-evaluated on read, or a `STATIC` membership list. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." + }, + { + "name": "Contacts", + "description": "Manage subscribers and per-contact custom data." + }, + { + "name": "Lists", + "description": "Self-serve list membership. These two endpoints accept sending-only keys so they can back a public subscribe/unsubscribe form; list management itself is dashboard-only." + }, + { + "name": "Domains", + "description": "Register sending domains and manage SES verification." + }, + { + "name": "Templates", + "description": "Reusable email templates referenced by send operations." + }, + { + "name": "Webhooks", + "description": "User-managed outbound webhooks for email and contact events." + }, + { + "name": "API Keys", + "description": "Project-scoped sending/API credentials. All four operations are documented; minting and rotation answer with a one-time reveal link rather than the secret, so a delegated caller never receives a key it could use." + }, + { + "name": "Suppression", + "description": "Project-scoped suppression list. Hard bounces and complaints land here automatically." + }, + { + "name": "Workflows", + "description": "Event-triggered automations and the contact runs through them. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." + }, + { + "name": "Events", + "description": "Track custom contact events from your application." + }, + { + "name": "Analytics", + "description": "Aggregate sending and engagement metrics. Every read is bounded to a window of at most 90 days and cached for 15 minutes." + }, + { + "name": "Deliverability", + "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": "Usage", + "description": "Current email usage against the monthly and daily limits the platform enforces." + }, + { + "name": "Projects", + "description": "The project a credential is scoped to, and creating new ones. Configuring an existing project stays a dashboard action." + }, + { + "name": "Mailboxes", + "description": "Real receiving mailboxes on your verified domains. Creating one changes how that domain's mail is routed; the messages a mailbox receives are not part of this API." + }, + { + "name": "Verify", + "description": "Open email-validation endpoint (no auth required). Used by the marketing-site verifier." + }, + { + "name": "Validation", + "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": "Topics", + "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." + } + ], "components": { - "parameters": {}, + "securitySchemes": { + "ApiKeyAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API Key", + "description": "API key authentication. Use a `sk_*` (FULL) or `pk_*` (SENDING_ONLY) key as the bearer token. Public keys (`pk_*`) are restricted to the email-send endpoints and the self-serve list subscribe/unsubscribe pair; every other endpoint — event tracking included — answers 403 for them. In scope terms (used by `/api/v1/*` operations): FULL keys hold every scope; SENDING_ONLY keys hold only `emails:send`." + }, + "SessionAuth": { + "type": "apiKey", + "in": "cookie", + "name": "better-auth.session_token", + "description": "BetterAuth session cookie. Used by the dashboard / browser clients. When present, the active project is taken from the `x-project-id` header." + }, + "OAuth2": { + "type": "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", + "tokenUrl": "https://app.sendly.now/api/auth/oauth2/token", + "scopes": { + "emails:send": "Send emails from your verified domains", + "emails:read": "View the emails you have sent and their delivery status", + "contacts:read": "View your contacts and their custom fields", + "contacts:write": "Create, update, and delete your contacts", + "campaigns:read": "View your campaigns and their performance", + "campaigns:write": "Create, edit, and organize your campaigns", + "segments:read": "View your segments and who belongs to them", + "segments:write": "Create, edit, and delete your segments", + "workflows:read": "View your automation workflows and their runs", + "workflows:write": "Create, edit, enable, and delete your automation workflows", + "templates:read": "View your email templates", + "templates:write": "Create, edit, and delete your email templates", + "domains:read": "View your sending domains and their verification status", + "domains:write": "Add and remove sending domains, and trigger verification", + "webhooks:read": "View your webhook endpoints and their delivery history", + "webhooks:write": "Create, edit, and delete your webhook endpoints", + "suppression:read": "View the addresses on your suppression list", + "suppression:write": "Add and remove addresses on your suppression list", + "analytics:read": "View your sending analytics and engagement metrics", + "usage:read": "View your usage totals and billing limits", + "events:read": "View the custom events your application has recorded", + "events:write": "Record custom events for your contacts", + "projects:read": "View your projects and their settings", + "projects:write": "Create new projects on your account", + "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:send": "Send or schedule your campaigns to their audience", + "mailboxes:read": "View the mailboxes on your domains and their settings", + "mailboxes:write": "Create and delete mailboxes on your verified domains", + "emails:test": "Send test emails to your own address from the Sendly sandbox", + "deliverability:read": "Check why mail from one of your domains is not arriving", + "mailboxes:send": "Write and send new email from your hosted mailboxes, as that address", + "validation:read": "View your email validation runs and their results", + "validation:write": "Check whether email addresses can receive mail — this is billed per address", + "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", + "lists:read": "View your subscriber lists and who is on them", + "lists:write": "Create, rename, and delete your subscriber lists" + } + } + } + } + }, "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.", + "Error": { + "type": "object", "properties": { - "domain": { - "maxLength": 253, - "minLength": 3, - "type": "string" - }, - "projectId": { - "format": "uuid", - "type": "string" - }, - "region": { - "description": "Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region.", + "success": { + "type": "boolean", "enum": [ - "us-east-1", - "us-west-2", - "eu-west-1" - ], - "type": "string" - } - }, - "required": [ - "domain" - ], - "type": "object" - }, - "AddSuppression": { - "description": "Body for POST /api/suppression — manually add an email to the suppression list.", - "properties": { - "email": { - "format": "email", - "type": "string" + false + ] }, - "reason": { - "default": "MANUAL", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ], - "type": "string" + "error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "string" + }, + "details": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": {} + } + }, + "required": [ + "errors" + ] + } + }, + "required": [ + "message", + "code" + ] } }, "required": [ - "email" + "error" ], - "type": "object" + "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`." }, - "AnalyticsCampaignStatsV1": { - "description": "Campaign counters and engagement over the window.", + "Problem": { + "type": "object", "properties": { - "active": { - "description": "Campaigns in DRAFT or SCHEDULED.", - "type": "integer" + "type": { + "type": "string", + "format": "uri", + "description": "Dereferenceable URI identifying the error class, anchored on the docs errors page." }, - "average_click_rate": { - "type": "number" + "title": { + "type": "string", + "description": "Short, stable summary — the same for every occurrence of a `type`." }, - "average_open_rate": { - "description": "Percentage, one decimal place.", - "type": "number" + "status": { + "type": "integer", + "description": "HTTP status code, repeated in the body." }, - "completed": { - "type": "integer" + "detail": { + "type": "string", + "description": "Explanation specific to this occurrence." }, - "total": { - "type": "integer" + "instance": { + "type": "string", + "description": "Request path the failure occurred on." }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" - } - }, - "required": [ - "total", - "active", - "completed", - "average_open_rate", - "average_click_rate", - "window" - ], - "type": "object" - }, - "AnalyticsTimeseriesV1": { - "description": "Daily email counters across the window. Every day in range is present, zero-filled.", - "properties": { - "data": { + "code": { + "type": "string", + "description": "Machine-readable lowercase error code, e.g. `scope_missing`." + }, + "request_id": { + "type": "string", + "description": "Correlation id — quote it in support requests." + }, + "errors": { + "type": "array", "items": { + "type": "object", "properties": { - "bounces": { - "type": "integer" - }, - "clicks": { - "type": "integer" + "pointer": { + "type": "string", + "description": "RFC 6901 JSON Pointer to the offending field." }, - "date": { - "format": "date-time", + "code": { "type": "string" }, - "delivered": { - "type": "integer" - }, - "emails": { - "type": "integer" - }, - "opens": { - "type": "integer" + "message": { + "type": "string" } }, "required": [ - "date", - "emails", - "delivered", - "opens", - "clicks", - "bounces" - ], - "type": "object" + "pointer", + "code", + "message" + ] }, - "type": "array" - }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" + "description": "Field-level failures. Present on 422 `validation_error` responses." } }, "required": [ - "data", - "window" + "type", + "title", + "status", + "code" ], - "type": "object" + "description": "RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface." }, - "AnalyticsTopCampaignsV1": { - "description": "Sent campaigns ranked by open rate.", + "SuccessEmpty": { + "type": "object", "properties": { - "data": { - "items": { - "properties": { - "click_rate": { - "type": "number" - }, - "clicked": { - "type": "integer" - }, - "id": { - "format": "uuid", - "type": "string" - }, - "open_rate": { - "type": "number" - }, - "opened": { - "type": "integer" - }, - "sent": { - "type": "integer" - }, - "subject": { - "type": "string" - } - }, - "required": [ - "id", - "subject", - "sent", - "opened", - "clicked", - "open_rate", - "click_rate" - ], - "type": "object" - }, - "type": "array" - }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" + "success": { + "type": "boolean", + "enum": [ + true + ] } }, "required": [ - "data", - "window" + "success" ], - "type": "object" + "description": "Bare success envelope with no payload." }, - "AnalyticsWindowV1": { - "description": "The time range this response was computed over, after the 90-day clamp.", + "IdResponse": { + "type": "object", "properties": { - "from": { - "format": "date-time", - "type": "string" + "success": { + "type": "boolean", + "enum": [ + true + ] }, - "to": { - "format": "date-time", - "type": "string" + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] } }, "required": [ - "from", - "to" + "success", + "data" ], - "type": "object" + "description": "Success envelope carrying the affected resource's id, e.g. after a delete." }, - "ApiKey": { - "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.", + "Contact": { + "type": "object", "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "id": { + "type": "string", + "format": "uuid" }, - "domainId": { - "format": "uuid", - "type": [ - "string", - "null" - ] + "projectId": { + "type": "string", + "format": "uuid" }, - "id": { - "format": "uuid", - "type": "string" + "email": { + "type": "string", + "format": "email" }, - "lastFour": { - "description": "Last 4 characters of the token — the only fragment of the secret that survives creation.", - "type": "string" + "subscribed": { + "type": "boolean" }, - "lastUsedAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", + "customFields": { "type": [ - "string", + "object", "null" - ] - }, - "name": { - "type": "string" - }, - "permission": { - "enum": [ - "FULL", - "SENDING_ONLY" ], - "type": "string" - }, - "projectId": { - "format": "uuid", - "type": "string" + "additionalProperties": {} }, - "revokedAt": { - "description": "Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses.", + "createdAt": { + "type": "string", "format": "date-time", - "type": [ - "string", - "null" - ] + "description": "ISO 8601 datetime string" }, - "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.", - "items": { - "enum": [ - "emails:send", - "emails:read", - "contacts:read", - "contacts:write", - "campaigns:read", - "campaigns:write", - "segments:read", - "segments:write", - "workflows:read", - "workflows:write", - "templates:read", - "templates:write", - "domains:read", - "domains:write", - "webhooks:read", - "webhooks:write", - "suppression:read", - "suppression:write", - "analytics:read", - "usage:read", - "events:read", - "events:write", - "projects:read", - "projects:write", - "api-keys:read", - "api-keys:write", - "campaigns:send", - "mailboxes:read", - "mailboxes:write", - "emails:test" - ], - "type": "string" - }, - "type": "array" + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" } }, "required": [ "id", "projectId", - "name", - "lastFour", - "permission", - "scopes", - "domainId", - "lastUsedAt", + "email", + "subscribed", "createdAt", - "revokedAt" + "updatedAt" ], - "type": "object" + "description": "A subscriber/contact within a project." }, - "ApiKeyListResponse": { - "description": "Every API key on the project, including revoked ones — filter on `revokedAt` for live keys.", + "ContactListResponse": { + "type": "object", "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/ApiKey" - }, - "type": "array" - }, "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Contact" + } + }, + "total": { + "type": "integer" + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Cursor for the next page, or null on the last page." + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "data", + "total", + "nextCursor", + "hasMore" + ] } }, "required": [ "success", "data" ], - "type": "object" + "description": "Cursor-paginated list of contacts." }, - "AppPassword": { - "description": "An IMAP/SMTP credential for one mailbox, described but never reproduced.", + "Template": { + "type": "object", "properties": { - "createdAt": { - "format": "date-time", + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "name": { "type": "string" }, - "id": { - "format": "uuid", + "description": { + "type": [ + "string", + "null" + ] + }, + "subject": { "type": "string" }, - "lastFour": { - "description": "The last four characters of the secret — enough to tell two credentials apart, and nothing more.", + "body": { "type": "string" }, - "lastUsedAt": { - "description": "Null until a mail client has authenticated with it at least once.", - "format": "date-time", + "from": { + "type": "string", + "format": "email" + }, + "fromName": { "type": [ "string", "null" ] }, - "name": { - "description": "What the credential is for, e.g. `Thunderbird on my laptop`.", - "type": "string" + "replyTo": { + "type": [ + "string", + "null" + ], + "format": "email" }, - "scopes": { - "description": "Which protocols this password may authenticate. `imap` reads, `smtp` sends.", - "items": { - "enum": [ - "imap", - "smtp" - ], - "type": "string" - }, - "type": "array" + "emailCategory": { + "type": "string", + "enum": [ + "MARKETING", + "TRANSACTIONAL", + "SELF_MANAGED_UNSUBSCRIBE" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" } }, "required": [ "id", + "projectId", "name", - "scopes", - "lastFour", - "lastUsedAt", - "createdAt" + "subject", + "body", + "from", + "emailCategory", + "createdAt", + "updatedAt" ], - "type": "object" + "description": "A reusable email template." }, - "AppPasswordReveal": { - "description": "A newly created app password, handed over as a one-time link rather than as a secret.", + "TemplateListResponse": { + "type": "object", "properties": { - "id": { - "format": "uuid", - "type": "string" - }, - "revealExpiresAt": { - "description": "When the link stops working. Five minutes after creation; the password itself does not expire.", - "format": "date-time", - "type": "string" + "success": { + "type": "boolean", + "enum": [ + true + ] }, - "revealUrl": { - "description": "A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "id", - "revealUrl", - "revealExpiresAt" - ], - "type": "object" - }, - "BatchEntryResult": { - "description": "Per-row result in a batch send response.", - "properties": { "data": { - "$ref": "#/components/schemas/SendEmailData" - }, - "error": { + "type": "object", "properties": { - "code": { - "type": "string" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } }, - "message": { - "type": "string" + "total": { + "type": "integer" + }, + "cursor": { + "type": "string", + "description": "Cursor for the next page; omitted on the last page." + }, + "hasMore": { + "type": "boolean" } }, "required": [ - "message", - "code" - ], - "type": "object" - }, - "index": { - "type": "integer" - }, - "status": { - "enum": [ - "ok", - "error" - ], - "type": "string" - } - }, - "required": [ - "index", - "status" - ], - "type": "object" - }, - "BatchSendBody": { - "description": "Batch send wrapper. Up to 100 entries.", - "properties": { - "emails": { - "items": { - "$ref": "#/components/schemas/SendEmail" - }, - "maxItems": 100, - "minItems": 1, - "type": "array" - } - }, - "required": [ - "emails" - ], - "type": "object" - }, - "BatchSendResponse": { - "description": "Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200.", - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/BatchEntryResult" - }, - "type": "array" - }, - "success": { - "type": "boolean" + "data", + "total", + "hasMore" + ] } }, "required": [ "success", "data" ], - "type": "object" + "description": "Cursor-paginated list of templates." }, - "CampaignV1": { - "description": "A campaign as exposed on the v1 API.", + "Snippet": { + "type": "object", "properties": { - "audience_type": { - "enum": [ - "ALL", - "FILTERED", - "SEGMENT" - ], - "type": "string" - }, - "created_at": { - "format": "date-time", - "type": "string" - }, "id": { - "format": "uuid", - "type": "string" + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" }, "name": { - "type": "string" + "type": "string", + "description": "The literal identifier a template includes with `{{> name}}`." }, - "scheduled_at": { - "format": "date-time", + "description": { "type": [ "string", "null" ] }, - "sent_at": { + "body": { + "type": "string", + "description": "Template markup. Values it interpolates are escaped like any other." + }, + "createdAt": { + "type": "string", "format": "date-time", - "type": [ - "string", - "null" + "description": "ISO 8601 datetime string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + } + }, + "required": [ + "id", + "projectId", + "name", + "body", + "createdAt", + "updatedAt" + ], + "description": "A reusable fragment of template markup." + }, + "SnippetListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true ] }, - "stats": { + "data": { + "type": "object", "properties": { - "bounced": { - "type": "integer" - }, - "clicked": { - "type": "integer" - }, - "delivered": { - "type": "integer" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Snippet" + } }, - "opened": { + "total": { "type": "integer" }, - "sent": { - "type": "integer" + "cursor": { + "type": "string", + "description": "Cursor for the next page; omitted on the last page." }, - "total_recipients": { - "type": "integer" + "hasMore": { + "type": "boolean" } }, "required": [ - "total_recipients", - "sent", - "delivered", - "opened", - "clicked", - "bounced" - ], - "type": "object" - }, - "status": { - "enum": [ - "DRAFT", - "SCHEDULED", - "SENDING", - "PAUSED", - "SENT", - "CANCELLED" - ], - "type": "string" - }, - "subject": { - "type": "string" + "data", + "total", + "hasMore" + ] } }, "required": [ - "id", - "name", - "status", - "subject", - "audience_type", - "scheduled_at", - "sent_at", - "created_at", - "stats" + "success", + "data" ], - "type": "object" + "description": "Cursor-paginated list of snippets." }, - "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`.", + "EmailDeliveryStatus": { + "type": "string", + "enum": [ + "PENDING", + "SENDING", + "SENT", + "DELIVERED", + "RECEIVED", + "BOUNCED", + "FAILED", + "REJECTED", + "RENDERING_FAILURE", + "DELIVERY_DELAY", + "CANCELLED" + ], + "description": "Delivery lifecycle of the message. Engagement is reported separately." + }, + "Domain": { + "type": "object", "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`.", - "enum": [ - "ALL", - "FILTERED", - "SEGMENT" - ], - "type": "string" + "id": { + "type": "string", + "format": "uuid" }, - "body": { - "minLength": 1, - "type": "string" + "projectId": { + "type": "string", + "format": "uuid" }, - "description": { - "maxLength": 500, + "name": { "type": "string" }, - "from": { - "description": "Sender address. Its domain must be verified for this project.", - "format": "email", - "type": "string" + "verified": { + "type": "boolean" }, - "from_name": { - "maxLength": 100, + "region": { "type": [ "string", "null" ] }, - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" + "dkim": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "name", + "value" + ] + } }, - "reply_to": { - "format": "email", + "mailFromDomain": { "type": [ "string", "null" - ] - }, - "segment_id": { - "format": "uuid", - "type": "string" - }, - "subject": { - "minLength": 1, - "type": "string" + ], + "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`)." }, - "type": { - "default": "MARKETING", + "mailFromDomainStatus": { + "type": [ + "string", + "null" + ], + "enum": [ + "Pending", + "Success", + "Failed", + "TemporaryFailure", + "NotConfigured", + null + ], + "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it." + }, + "stream": { + "type": [ + "string", + "null" + ], "enum": [ "TRANSACTIONAL", "MARKETING", - "HEADLESS" + null ], - "type": "string" + "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." + }, + "streamDefault": { + "type": "boolean", + "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)." + }, + "defaultFromAddress": { + "type": [ + "string", + "null" + ], + "description": "The address a send on this stream uses when it names none. Always on this identity's own host." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" } }, "required": [ + "id", + "projectId", "name", - "subject", - "body", - "from", - "audience_type" + "verified", + "createdAt", + "updatedAt" ], - "type": "object" + "description": "A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation." }, - "CampaignV1Deleted": { - "description": "Acknowledgement that a campaign was deleted.", + "DomainListResponse": { + "type": "object", "properties": { - "deleted": { + "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] }, - "id": { - "format": "uuid", - "type": "string" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Domain" + } } }, "required": [ - "id", - "deleted" + "success", + "data" ], - "type": "object" + "description": "List of all domains for the auth'd project." }, - "CampaignV1List": { - "description": "Cursor-paginated list of campaigns.", + "DomainVerificationStatus": { + "type": "object", "properties": { - "data": { + "verified": { + "type": "boolean" + }, + "mxRecords": { + "type": "array", "items": { - "$ref": "#/components/schemas/CampaignV1" - }, - "type": "array" + "type": "string" + } }, - "has_more": { - "type": "boolean" + "dkim": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "name", + "value" + ] + } }, - "next_cursor": { - "description": "Pass as `after` to fetch the next page. `null` on the last page.", + "mailFromDomain": { "type": [ "string", "null" ] - } - }, - "required": [ - "data", - "has_more", - "next_cursor" - ], - "type": "object" - }, - "CampaignV1Send": { - "description": "Body for POST /api/v1/campaigns/{id}/send.", - "properties": { - "scheduled_for": { - "description": "RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately.", - "format": "date-time", - "type": "string" - } - }, - "type": "object" - }, - "CampaignV1Stats": { - "description": "Materialized delivery and engagement counters for one campaign.", - "properties": { - "bounce_rate": { - "type": "number" - }, - "bounced": { - "type": "integer" - }, - "click_rate": { - "type": "number" - }, - "clicked": { - "type": "integer" - }, - "delivered": { - "type": "integer" - }, - "delivery_rate": { - "type": "number" - }, - "open_rate": { - "type": "number" - }, - "opened": { - "type": "integer" - }, - "sent": { - "type": "integer" }, - "total_recipients": { - "type": "integer" + "mailFromDomainStatus": { + "type": [ + "string", + "null" + ], + "enum": [ + "Pending", + "Success", + "Failed", + "TemporaryFailure", + "NotConfigured", + null + ], + "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it." } }, "required": [ - "total_recipients", - "sent", - "delivered", - "opened", - "clicked", - "bounced", - "open_rate", - "click_rate", - "bounce_rate", - "delivery_rate" + "verified" ], - "type": "object" + "description": "Outcome of a verification check against SES." }, - "CampaignV1Update": { - "description": "Body for PATCH /api/v1/campaigns/{id}. All fields optional.", + "Email": { + "type": "object", "properties": { - "audience_condition": { - "$ref": "#/components/schemas/FilterConditionV1" + "id": { + "type": "string", + "format": "uuid" }, - "audience_type": { - "enum": [ - "ALL", - "FILTERED", - "SEGMENT" - ], - "type": "string" + "projectId": { + "type": "string", + "format": "uuid" }, - "body": { - "minLength": 1, + "from": { "type": "string" }, - "description": { - "maxLength": 500, + "to": { "type": "string" }, - "from": { - "description": "Sender address. Its domain must be verified for this project.", - "format": "email", + "subject": { "type": "string" }, - "from_name": { - "maxLength": 100, + "status": { + "$ref": "#/components/schemas/EmailDeliveryStatus" + }, + "openedAt": { "type": [ "string", "null" - ] - }, - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" + ], + "format": "date-time", + "description": "First open, or null." }, - "reply_to": { - "format": "email", + "clickedAt": { "type": [ "string", "null" - ] - }, - "segment_id": { - "format": "uuid", - "type": "string" - }, - "subject": { - "minLength": 1, - "type": "string" - }, - "type": { - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "HEADLESS" ], - "type": "string" - } - }, - "type": "object" - }, - "Contact": { - "description": "A subscriber/contact within a project.", - "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", "format": "date-time", - "type": "string" + "description": "First click, or null." }, - "customFields": { - "additionalProperties": {}, + "complainedAt": { "type": [ - "object", + "string", "null" - ] + ], + "format": "date-time", + "description": "Spam complaint, or null." }, - "email": { - "format": "email", - "type": "string" + "opens": { + "type": "integer", + "description": "Total opens recorded." }, - "id": { - "format": "uuid", - "type": "string" + "clicks": { + "type": "integer", + "description": "Total clicks recorded." }, - "projectId": { - "format": "uuid", - "type": "string" + "tags": { + "type": "array", + "items": { + "type": "string" + } }, - "subscribed": { - "type": "boolean" + "error": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" }, "updatedAt": { - "description": "ISO 8601 datetime string", + "type": "string", "format": "date-time", - "type": "string" + "description": "ISO 8601 datetime string" } }, "required": [ "id", "projectId", - "email", - "subscribed", + "from", + "to", + "subject", + "status", + "openedAt", + "clickedAt", + "complainedAt", + "opens", + "clicks", + "tags", "createdAt", "updatedAt" ], - "type": "object" + "description": "A sent (or queued) transactional email." }, - "ContactBulkCreateBody": { - "description": "Bulk create up to 1000 contacts.", + "EmailEvent": { + "type": "object", "properties": { - "contacts": { - "items": { - "$ref": "#/components/schemas/CreateContact" - }, - "maxItems": 1000, - "minItems": 1, - "type": "array" + "id": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/components/schemas/EmailDeliveryStatus" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" } }, "required": [ - "contacts" + "id", + "status", + "timestamp" ], - "type": "object" + "description": "One transition in a message's delivery history." }, - "ContactBulkDeleteBody": { - "description": "Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each).", - "properties": { - "emails": { - "items": { - "format": "email", - "type": "string" - }, - "maxItems": 1000, - "type": "array" + "EmailWithEvents": { + "allOf": [ + { + "$ref": "#/components/schemas/Email" }, - "ids": { - "items": { - "format": "uuid", - "type": "string" + { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailEvent" + }, + "description": "Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource." + } }, - "maxItems": 1000, - "type": "array" + "required": [ + "events" + ] } - }, - "type": "object" + ], + "description": "A transactional email together with its delivery history." }, - "ContactListResponse": { - "description": "Cursor-paginated list of contacts.", + "SendEmailRecipientResult": { + "type": "object", "properties": { - "data": { + "contact": { + "type": "object", "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/Contact" - }, - "type": "array" - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "description": "Cursor for the next page, or null on the last page.", - "type": [ - "string", - "null" - ] + "id": { + "type": "string", + "format": "uuid" }, - "total": { - "type": "integer" + "email": { + "type": "string", + "format": "email" } }, "required": [ - "data", - "total", - "nextCursor", - "hasMore" - ], - "type": "object" + "id", + "email" + ] }, - "success": { - "enum": [ - true - ], - "type": "boolean" + "email": { + "type": "string", + "format": "uuid" } }, "required": [ - "success", - "data" + "contact", + "email" ], - "type": "object" + "description": "Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient." }, - "CreateApiKeyBody": { + "SendEmailData": { + "type": "object", "properties": { - "domainId": { - "format": "uuid", - "type": [ - "string", - "null" - ] - }, - "name": { - "maxLength": 120, - "minLength": 1, - "type": "string" + "emails": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SendEmailRecipientResult" + } }, - "permission": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + } + }, + "required": [ + "emails", + "timestamp" + ], + "description": "Result of a send (`executeSendEmail`): one `emails` entry per recipient — a single request with an array `to` fans out to several — plus the send `timestamp`." + }, + "SendEmailResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", "enum": [ - "FULL", - "SENDING_ONLY" - ], - "type": "string" + true + ] }, - "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", - "emails:read", - "contacts:read", - "contacts:write", - "campaigns:read", - "campaigns:write", - "segments:read", - "segments:write", - "workflows:read", - "workflows:write", - "templates:read", - "templates:write", - "domains:read", - "domains:write", - "webhooks:read", - "webhooks:write", - "suppression:read", - "suppression:write", - "analytics:read", - "usage:read", - "events:read", - "events:write", - "projects:read", - "projects:write", - "api-keys:read", - "api-keys:write", - "campaigns:send", - "mailboxes:read", - "mailboxes:write", - "emails:test" - ], - "type": "string" - }, - "type": "array" + "data": { + "$ref": "#/components/schemas/SendEmailData" } }, "required": [ - "name" + "success", + "data" ], - "type": "object" + "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." }, - "CreateAppPassword": { - "description": "Body for POST /api/mailboxes/:id/app-passwords.", + "BatchEntryResult": { + "type": "object", "properties": { - "name": { - "maxLength": 60, - "minLength": 1, - "type": "string" + "index": { + "type": "integer" }, - "scopes": { - "default": [ - "imap", - "smtp" - ], - "items": { - "enum": [ - "imap", - "smtp" - ], - "type": "string" + "status": { + "type": "string", + "enum": [ + "ok", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/SendEmailData" + }, + "error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "string" + } }, - "minItems": 1, - "type": "array" + "required": [ + "message", + "code" + ] } }, "required": [ - "name" + "index", + "status" ], - "type": "object" + "description": "Per-row result in a batch send response." }, - "CreateContact": { - "description": "Body for POST /api/contacts and /api/contacts/upsert.", + "BatchSendResponse": { + "type": "object", "properties": { - "customFields": { - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", - "type": "object" - }, - "email": { - "format": "email", - "type": "string" - }, - "subscribed": { - "default": true, + "success": { "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchEntryResult" + } } }, "required": [ - "email" + "success", + "data" ], - "type": "object" + "description": "Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200." }, - "CreateMailboxBody": { + "BatchSendBody": { + "type": "object", "properties": { - "displayName": { - "maxLength": 120, - "type": "string" - }, - "domainId": { - "description": "A VERIFIED domain belonging to this project.", - "format": "uuid", - "type": "string" - }, - "localPart": { - "description": "The part before the `@`, e.g. `support`. Lowercased server-side.", - "maxLength": 64, - "minLength": 1, - "type": "string" - }, - "projectId": { - "description": "Defaults to the project the credential resolves to. Naming a different one is refused.", - "format": "uuid", - "type": "string" - }, - "quotaBytes": { - "description": "NOT IMPLEMENTED — sending any value answers 400.", - "exclusiveMinimum": 0, - "type": "integer" + "emails": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SendEmail" + }, + "minItems": 1, + "maxItems": 100 } }, "required": [ - "domainId", - "localPart" + "emails" ], - "type": "object" + "description": "Batch send wrapper. Up to 100 entries." }, - "CreateTemplate": { - "description": "Body for POST /api/templates.", + "SendEmail": { + "type": "object", "properties": { - "body": { - "minLength": 1, - "type": "string" - }, - "description": { - "maxLength": 500, - "type": "string" - }, - "from": { - "format": "email", - "type": "string" - }, - "fromName": { - "maxLength": 100, - "type": [ - "string", - "null" + "to": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + } + ] + } + } ] }, - "name": { - "maxLength": 100, + "subject": { + "type": "string", "minLength": 1, + "maxLength": 998, + "pattern": "^[^\\r\\n]*$" + }, + "body": { + "type": "string", + "minLength": 1 + }, + "template": { + "type": "string", + "format": "uuid" + }, + "subscribed": { + "type": "boolean" + }, + "name": { "type": "string" }, - "replyTo": { - "format": "email", - "type": [ - "string", - "null" + "from": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[^\\r\\n]*$" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + } ] }, - "subject": { - "minLength": 1, - "type": "string" + "reply": { + "type": "string", + "format": "email" }, - "type": { - "default": "MARKETING", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "HEADLESS" - ], - "type": "string" + "headers": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 998, + "pattern": "^[^\\r\\n]*$" + } + }, + "data": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[^\\r\\n\"]+$" + }, + "content": { + "type": "string", + "minLength": 1 + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "contentId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[^<>\\r\\n]+$" + }, + "disposition": { + "type": "string", + "enum": [ + "attachment", + "inline" + ], + "default": "attachment" + } + }, + "required": [ + "filename", + "content", + "contentType" + ] + }, + "maxItems": 10 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "maxItems": 10 + }, + "cc": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } } }, "required": [ - "name", - "subject", - "body", - "from" + "to" ], - "type": "object" + "description": "Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required." }, - "CreateWebhook": { - "description": "Body for POST /api/webhooks — register a user webhook for one or more events.", + "ContactBulkCreateBody": { + "type": "object", "properties": { - "eventTypes": { + "contacts": { + "type": "array", "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" + "$ref": "#/components/schemas/CreateContact" }, "minItems": 1, - "type": "array" - }, - "url": { - "format": "uri", - "type": "string" + "maxItems": 1000 } }, "required": [ - "url", - "eventTypes" + "contacts" ], - "type": "object" + "description": "Bulk create up to 1000 contacts." }, - "Domain": { - "description": "A sending domain registered with SES.", + "CreateContact": { + "type": "object", "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" - }, - "dkim": { - "items": { - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "value" - ], - "type": "object" - }, - "type": "array" - }, - "id": { - "format": "uuid", - "type": "string" - }, - "mailFromDomain": { - "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`).", - "type": [ - "string", - "null" - ] - }, - "mailFromStatus": { - "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.", - "enum": [ - "Pending", - "Success", - "Failed", - "TemporaryFailure", - "NotConfigured", - null - ], - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "projectId": { - "format": "uuid", - "type": "string" - }, - "region": { - "type": [ - "string", - "null" - ] + "email": { + "type": "string", + "format": "email" }, - "updatedAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "subscribed": { + "type": "boolean", + "default": true }, - "verified": { - "type": "boolean" + "customFields": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." } }, "required": [ - "id", - "projectId", - "name", - "verified", - "createdAt", - "updatedAt" + "email" ], - "type": "object" + "description": "Body for POST /api/contacts and /api/contacts/upsert." }, - "DomainListResponse": { - "description": "List of all domains for the auth'd project.", + "ContactBulkDeleteBody": { + "type": "object", "properties": { - "data": { + "ids": { + "type": "array", "items": { - "$ref": "#/components/schemas/Domain" + "type": "string", + "format": "uuid" }, - "type": "array" + "maxItems": 1000 }, - "success": { - "enum": [ - true - ], - "type": "boolean" + "emails": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 1000 } }, - "required": [ - "success", - "data" - ], - "type": "object" + "description": "Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each)." }, - "DomainVerificationStatus": { - "description": "Outcome of a verification check against SES.", + "UpdateContactBody": { + "type": "object", "properties": { - "dkim": { + "subscribed": { + "type": "boolean" + }, + "customFields": { + "type": "object", + "additionalProperties": {} + } + }, + "description": "Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses." + }, + "Suppression": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "format": "email" + }, + "reason": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ] + }, + "source": { + "type": "string", + "enum": [ + "SES_WEBHOOK", + "API", + "DASHBOARD" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + } + }, + "required": [ + "id", + "projectId", + "email", + "reason", + "source", + "createdAt" + ], + "description": "A single suppressed-email record." + }, + "SuppressionListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "array", "items": { - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "value" - ], - "type": "object" - }, - "type": "array" + "$ref": "#/components/schemas/Suppression" + } }, - "mailFromDomain": { + "nextCursor": { "type": [ "string", "null" ] }, - "mailFromStatus": { - "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.", - "enum": [ - "Pending", - "Success", - "Failed", - "TemporaryFailure", - "NotConfigured", - null - ], + "cursor": { "type": [ "string", "null" ] }, - "mxRecords": { - "items": { - "type": "string" - }, - "type": "array" - }, - "verified": { + "hasMore": { "type": "boolean" } }, "required": [ - "verified" + "success", + "data" ], - "type": "object" + "description": "Cursor-paginated list of suppressions." }, - "Email": { - "description": "A sent (or queued) transactional email.", + "SuppressionCheckResponse": { + "type": "object", "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "suppressed": { + "type": "boolean" }, - "error": { - "type": [ - "string", - "null" + "reason": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" ] }, - "from": { - "type": "string" + "source": { + "type": "string", + "enum": [ + "SES_WEBHOOK", + "API", + "DASHBOARD" + ] }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + } + }, + "required": [ + "suppressed" + ], + "description": "Result of GET /api/suppression/{email} — whether the address is suppressed." + }, + "Webhook": { + "type": "object", + "properties": { "id": { - "format": "uuid", - "type": "string" + "type": "string", + "format": "uuid" }, "projectId": { - "format": "uuid", - "type": "string" + "type": "string", + "format": "uuid" + }, + "url": { + "type": "string", + "format": "uri" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ] + } }, "status": { + "type": "string", "enum": [ - "PENDING", - "SENT", - "DELIVERED", - "OPENED", - "CLICKED", - "BOUNCED", - "COMPLAINED", - "FAILED" - ], - "type": "string" + "ACTIVE", + "PAUSED", + "DISABLED" + ] }, - "subject": { - "type": "string" + "consecutiveFailures": { + "type": "integer" }, - "tags": { - "items": { - "type": "string" - }, - "type": "array" + "disabledAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 datetime string" }, - "to": { + "lastFour": { "type": "string" }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + }, "updatedAt": { - "description": "ISO 8601 datetime string", + "type": "string", "format": "date-time", - "type": "string" + "description": "ISO 8601 datetime string" } }, "required": [ "id", "projectId", - "from", - "to", - "subject", + "url", + "eventTypes", "status", - "tags", + "consecutiveFailures", "createdAt", "updatedAt" ], - "type": "object" + "description": "A user-managed outbound webhook." }, - "EmailGetResponse": { - "description": "Single email with its events.", + "WebhookCreateResponse": { + "type": "object", "properties": { - "data": { - "$ref": "#/components/schemas/Email" - }, "success": { + "type": "boolean", "enum": [ true + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/Webhook" + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "Plaintext shared secret. Returned ONCE on create." + } + }, + "required": [ + "secret" + ] + } ], - "type": "boolean" + "description": "A user-managed outbound webhook." } }, "required": [ "success", "data" ], - "type": "object" + "description": "Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely." }, - "EmailListResponse": { - "description": "Cursor-paginated list of emails.", + "WebhookListResponse": { + "type": "object", "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/Email" - }, - "type": "array" - }, - "nextCursor": { - "type": [ - "string", - "null" + "success": { + "type": "boolean", + "enum": [ + true ] }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Webhook" + } + } + }, + "required": [ + "success", + "data" + ], + "description": "List of webhooks for the auth'd project." + }, + "WebhookGetResponse": { + "type": "object", + "properties": { "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "$ref": "#/components/schemas/Webhook" } }, "required": [ "success", "data" ], - "type": "object" + "description": "Single webhook (no secret)." }, - "EmailTestV1": { - "description": "Receipt for a sandbox test send.", + "WebhookRotateSecretResponse": { + "type": "object", "properties": { - "from": { - "description": "This project's sandbox sender — resolved server-side, never from the body.", - "format": "email", - "type": "string" - }, - "id": { - "description": "The Email row this send created.", - "format": "uuid", - "type": "string" - }, - "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.", + "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] }, - "status": { - "description": "Delivery status at the moment of the response — `PENDING` for a send still queued.", - "enum": [ - "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "OPENED", - "CLICKED", - "BOUNCED", - "COMPLAINED", - "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" - ], - "type": "string" - }, - "to": { - "description": "The recipient the message was queued for.", - "format": "email", - "type": "string" + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "secret": { + "type": "string", + "description": "New plaintext shared secret." + } + }, + "required": [ + "id", + "secret" + ] } }, "required": [ - "id", - "status", - "to", - "from", - "sandbox" + "success", + "data" ], - "type": "object" + "description": "Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once." }, - "EmailV1": { - "description": "Receipt for a single transactional send.", + "WebhookCall": { + "type": "object", "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" - }, "id": { - "description": "The Email row this send created. Quote it in support requests.", - "format": "uuid", + "type": "string", + "format": "uuid" + }, + "webhookId": { + "type": "string", + "format": "uuid" + }, + "eventType": { "type": "string" }, + "payload": { + "type": "object", + "additionalProperties": {} + }, "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.", + "type": "string", "enum": [ "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "OPENED", - "CLICKED", - "BOUNCED", - "COMPLAINED", - "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" - ], - "type": "string" + "SUCCESS", + "FAILED" + ] }, - "to": { - "description": "The recipient the message was queued for.", - "format": "email", - "type": "string" + "attempt": { + "type": "integer" + }, + "responseStatus": { + "type": [ + "integer", + "null" + ] + }, + "responseBody": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" } }, "required": [ "id", + "webhookId", + "eventType", + "payload", "status", - "to", - "from" + "attempt", + "createdAt" ], - "type": "object" + "description": "An attempted webhook delivery." }, - "Error": { - "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`.", + "WebhookCallsListResponse": { + "type": "object", "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": { + "type": "boolean", "enum": [ - false - ], + true + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookCall" + } + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "hasMore": { "type": "boolean" } }, "required": [ - "error" + "success", + "data" ], - "type": "object" + "description": "Cursor-paginated list of recent calls for a single webhook." }, - "EventNamesV1": { - "description": "Every distinct event name in the project, most frequent first.", + "TrackEventResponse": { + "type": "object", "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, "data": { - "items": { - "type": "string" + "type": "object", + "properties": { + "contact": { + "type": "string", + "format": "uuid" + }, + "event": { + "type": "string", + "format": "uuid" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + } }, - "type": "array" + "required": [ + "contact", + "event", + "timestamp" + ] } }, "required": [ + "success", "data" ], - "type": "object" + "description": "Response from POST /api/track." }, - "EventStatsV1": { - "description": "Per-name event counts over the applied window.", + "VerifyEmailResponse": { + "type": "object", "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, "data": { - "items": { - "properties": { - "count": { - "type": "integer" - }, - "name": { - "type": "string" - } + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" }, - "required": [ - "name", - "count" - ], - "type": "object" + "valid": { + "type": "boolean" + }, + "reason": { + "type": "string" + } }, - "type": "array" - }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" + "required": [ + "email", + "valid" + ], + "additionalProperties": {} } }, "required": [ - "data", - "window" + "success", + "data" ], - "type": "object" + "description": "Response from POST /api/verify — outcome of the syntax/MX/disposable check." }, - "EventTrackV1": { - "description": "Body for POST /api/v1/events.", + "EmailListResponse": { + "type": "object", "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" + "success": { + "type": "boolean", + "enum": [ + true + ] }, "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" + "type": "array", + "items": { + "$ref": "#/components/schemas/Email" + } }, - "name": { - "description": "Event name, e.g. `user.signup`.", - "maxLength": 200, - "minLength": 1, - "type": "string" + "nextCursor": { + "type": [ + "string", + "null" + ] } }, "required": [ - "name" + "success", + "data" ], - "type": "object" + "description": "Cursor-paginated list of emails." }, - "EventV1": { - "description": "A recorded custom event.", + "EmailResponse": { + "type": "object", "properties": { - "contact_id": { - "format": "uuid", - "type": [ - "string", - "null" + "success": { + "type": "boolean", + "enum": [ + true ] }, - "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" - ] - }, - "id": { - "format": "uuid", - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "contact_id", - "email_id", - "data", - "created_at" - ], - "type": "object" - }, - "EventV1List": { - "description": "Cursor-paginated list of events, newest first.", - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/EventV1" - }, - "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" - }, - "FilterConditionV1": { - "description": "A filter condition: one or more groups combined with `logic`.", - "properties": { - "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" - }, - "filters": { - "items": { - "$ref": "#/components/schemas/SegmentFilterV1" - }, - "type": "array" + "$ref": "#/components/schemas/Email" } }, "required": [ - "filters" + "success", + "data" ], - "type": "object" + "description": "A single email." }, - "IdResponse": { - "description": "Success envelope carrying the affected resource's id, e.g. after a delete.", + "EmailDetailResponse": { + "type": "object", "properties": { - "data": { - "properties": { - "id": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - }, "success": { + "type": "boolean", "enum": [ true - ], - "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" + ] }, "data": { - "additionalProperties": {}, - "description": "Custom fields to upsert onto the contact as part of subscribing.", - "type": "object" - }, - "email": { - "format": "email", - "type": "string" + "$ref": "#/components/schemas/EmailWithEvents" } }, "required": [ - "email" + "success", + "data" ], - "type": "object" + "description": "One email and its delivery history." }, "ListSubscribeResponse": { - "description": "Result of a list-subscribe call.", + "type": "object", "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, "data": { + "type": "object", "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" + "type": "string", + "format": "uuid" }, - "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.", + "status": { + "type": "string", "enum": [ "PENDING", "CONFIRMED", - "UNSUBSCRIBED", - null - ], + "UNSUBSCRIBED" + ] + }, + "created": { + "type": "boolean", + "description": "True when the membership row did not exist before this call." + }, + "previousStatus": { "type": [ "string", "null" - ] - }, - "status": { + ], "enum": [ "PENDING", "CONFIRMED", - "UNSUBSCRIBED" + "UNSUBSCRIBED", + null ], - "type": "string" + "description": "Status the membership held before this call; null when it did not exist. Use this rather than `created` to describe the transition to the user." + }, + "confirmToken": { + "type": "string", + "description": "Present only when the list has doubleOptIn enabled. Sendly does not send the confirmation email — deliver /api/lists/confirm-subscription?token= to the contact. Valid for 24 hours." } }, "required": [ @@ -1916,277 +1965,225 @@ "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" + "description": "Result of a list-subscribe call." }, "ListUnsubscribeResponse": { - "description": "Echoes the address that was unsubscribed.", + "type": "object", "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, "data": { + "type": "object", "properties": { "email": { - "format": "email", - "type": "string" + "type": "string", + "format": "email" } }, "required": [ "email" - ], - "type": "object" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" + ] } }, "required": [ "success", "data" ], - "type": "object" + "description": "Echoes the address that was unsubscribed." }, - "Mailbox": { - "description": "A receiving mailbox on one of the project's verified domains.", + "ApiKey": { + "type": "object", "properties": { - "address": { - "description": "The full mailbox address, e.g. `support@superbooks.io`.", - "format": "email", - "type": "string" - }, - "createdAt": { - "format": "date-time", - "type": "string" + "id": { + "type": "string", + "format": "uuid" }, - "displayName": { - "type": [ - "string", - "null" - ] + "projectId": { + "type": "string", + "format": "uuid" }, - "domainId": { - "description": "The verified domain this mailbox lives on.", - "format": "uuid", + "name": { "type": "string" }, - "id": { - "format": "uuid", - "type": "string" + "lastFour": { + "type": "string", + "description": "Last 4 characters of the token — the only fragment of the secret that survives creation." }, - "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" - ] + "legacyGrantPreset": { + "type": "string", + "enum": [ + "FULL", + "SENDING_ONLY" + ], + "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." }, - "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.", + "mode": { + "type": "string", "enum": [ - "PROVISIONING", - "ACTIVE", - "SUSPENDED", - "FAILED" + "LIVE", + "TEST" ], - "type": "string" - } - }, - "required": [ - "id", - "address", - "displayName", - "status", - "quotaBytes", - "domainId", - "createdAt" - ], - "type": "object" - }, - "MailboxDetail": { - "allOf": [ - { - "$ref": "#/components/schemas/Mailbox" + "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." }, - { - "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" - } + "scopes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "emails:send", + "emails:read", + "contacts:read", + "contacts:write", + "campaigns:read", + "campaigns:write", + "segments:read", + "segments:write", + "workflows:read", + "workflows:write", + "templates:read", + "templates:write", + "domains:read", + "domains:write", + "webhooks:read", + "webhooks:write", + "suppression:read", + "suppression:write", + "analytics:read", + "usage:read", + "events:read", + "events:write", + "projects:read", + "projects:write", + "api-keys:read", + "api-keys:write", + "campaigns:send", + "mailboxes:read", + "mailboxes:write", + "emails:test", + "deliverability:read", + "mailboxes:send", + "validation:read", + "validation:write", + "topics:read", + "topics:write", + "lists:read", + "lists:write" + ] }, - "required": [ - "settings" + "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." + }, + "domainId": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 datetime string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" + }, + "revokedAt": { + "type": [ + "string", + "null" ], - "type": "object" + "format": "date-time", + "description": "Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses." } + }, + "required": [ + "id", + "projectId", + "name", + "lastFour", + "legacyGrantPreset", + "mode", + "scopes", + "domainId", + "lastUsedAt", + "createdAt", + "revokedAt" ], - "description": "A mailbox plus its IMAP/SMTP connection settings." + "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." }, - "Problem": { - "description": "RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface.", + "ApiKeyListResponse": { + "type": "object", "properties": { - "code": { - "description": "Machine-readable lowercase error code, e.g. `scope_missing`.", - "type": "string" - }, - "detail": { - "description": "Explanation specific to this occurrence.", - "type": "string" + "success": { + "type": "boolean", + "enum": [ + true + ] }, - "errors": { - "description": "Field-level failures. Present on 422 `validation_error` responses.", + "data": { + "type": "array", "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" - }, - "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" + "$ref": "#/components/schemas/ApiKey" + } } }, "required": [ - "type", - "title", - "status", - "code" + "success", + "data" ], - "type": "object" + "description": "Every API key on the project, including revoked ones — filter on `revokedAt` for live keys." }, "ProjectRecord": { + "type": "object", "properties": { - "billingLimitCampaigns": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "disabled": { + "type": "boolean" + }, + "disabledReason": { "type": [ - "integer", + "string", "null" ] }, - "billingLimitInbound": { + "sandboxHandle": { "type": [ - "integer", + "string", + "null" + ], + "description": "Local-part of the sandbox quick-start sender; null until first derived." + }, + "stripeCustomerId": { + "type": [ + "string", "null" ] }, - "billingLimitTransactional": { + "stripeSubscriptionId": { "type": [ - "integer", + "string", "null" ] }, @@ -2196,74 +2193,57 @@ "null" ] }, - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" - }, - "disabled": { - "type": "boolean" - }, - "disabledReason": { + "billingLimitCampaigns": { "type": [ - "string", + "integer", "null" ] }, - "id": { - "format": "uuid", - "type": "string" - }, - "language": { - "description": "ISO 639-1 code for customer-facing content.", - "type": "string" - }, - "name": { - "type": "string" - }, - "organizationId": { + "billingLimitTransactional": { "type": [ - "string", + "integer", "null" ] }, - "sandboxHandle": { - "description": "Local-part of the sandbox quick-start sender; null until first derived.", + "billingLimitInbound": { "type": [ - "string", + "integer", "null" ] }, - "sesRegion": { - "type": [ - "string", - "null" + "tracking": { + "type": "string", + "enum": [ + "ENABLED", + "DISABLED", + "MARKETING_ONLY" ] }, - "stripeCustomerId": { + "sesRegion": { "type": [ "string", "null" ] }, - "stripeSubscriptionId": { + "language": { + "type": "string", + "description": "ISO 639-1 code for customer-facing content." + }, + "organizationId": { "type": [ "string", "null" ] }, - "tracking": { - "enum": [ - "ENABLED", - "DISABLED", - "MARKETING_ONLY" - ], - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime string" }, "updatedAt": { - "description": "ISO 8601 datetime string", + "type": "string", "format": "date-time", - "type": "string" + "description": "ISO 8601 datetime string" } }, "required": [ @@ -2284,133 +2264,286 @@ "organizationId", "createdAt", "updatedAt" - ], - "type": "object" + ] }, - "ProjectV1": { - "description": "The project the presented credential is scoped to.", + "CampaignV1List": { + "type": "object", "properties": { - "created_at": { - "format": "date-time", - "type": "string" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignV1" + } }, - "disabled": { - "description": "A disabled project sends nothing; every send is refused.", + "has_more": { "type": "boolean" }, - "id": { - "format": "uuid", - "type": "string" - }, - "language": { - "description": "ISO 639-1 code for customer-facing content.", - "type": "string" + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of campaigns." + }, + "CampaignV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, - "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.", + "status": { + "type": "string", + "enum": [ + "DRAFT", + "SCHEDULED", + "SENDING", + "PAUSED", + "SENT", + "CANCELLED" + ] + }, + "subject": { + "type": "string" + }, + "audience_type": { + "type": "string", + "enum": [ + "ALL", + "FILTERED", + "SEGMENT", + "LIST" + ] + }, + "list_id": { "type": [ "string", "null" - ] + ], + "format": "uuid" }, - "ses_region": { - "description": "Locked once the first domain is added.", + "topic_id": { "type": [ "string", "null" - ] + ], + "format": "uuid" }, - "tracking": { - "enum": [ - "ENABLED", - "DISABLED", - "MARKETING_ONLY" + "scheduled_at": { + "type": [ + "string", + "null" ], - "type": "string" + "format": "date-time" + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "stats": { + "type": "object", + "properties": { + "total_recipients": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "bounced": { + "type": "integer" + } + }, + "required": [ + "total_recipients", + "sent", + "delivered", + "opened", + "clicked", + "bounced" + ] } }, "required": [ "id", "name", - "disabled", - "sandbox_address", - "ses_region", - "tracking", - "language", - "created_at" + "status", + "subject", + "audience_type", + "list_id", + "topic_id", + "scheduled_at", + "sent_at", + "created_at", + "stats" ], - "type": "object" + "description": "A campaign as exposed on the v1 API." }, - "SegmentContactV1": { - "description": "A contact belonging to a segment.", + "CampaignV1Create": { + "type": "object", "properties": { - "created_at": { - "format": "date-time", - "type": "string" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 }, - "custom_fields": { - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", - "type": "object" + "description": { + "type": "string", + "maxLength": 500 }, - "email": { - "type": "string" + "subject": { + "type": "string", + "minLength": 1 }, - "id": { + "body": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "format": "email", + "description": "Sender address. Its domain must be verified for this project." + }, + "from_name": { + "type": [ + "string", + "null" + ], + "maxLength": 100 + }, + "reply_to": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "email_category": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ], + "default": "MARKETING" + }, + "audience_type": { + "type": "string", + "enum": [ + "ALL", + "FILTERED", + "SEGMENT", + "LIST" + ], + "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." + }, + "audience_condition": { + "$ref": "#/components/schemas/FilterConditionV1" + }, + "segment_id": { + "type": "string", + "format": "uuid" + }, + "list_id": { + "type": "string", "format": "uuid", - "type": "string" + "description": "Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign." }, - "subscribed": { - "type": "boolean" + "topic_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "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." } }, "required": [ - "id", - "email", - "subscribed", - "custom_fields", - "created_at" + "name", + "subject", + "body", + "from", + "audience_type" ], - "type": "object" + "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`." }, - "SegmentContactV1List": { - "description": "Cursor-paginated list of the contacts belonging to a segment.", + "FilterConditionV1": { + "type": "object", "properties": { - "data": { + "logic": { + "type": "string", + "enum": [ + "AND", + "OR" + ] + }, + "groups": { + "type": "array", "items": { - "$ref": "#/components/schemas/SegmentContactV1" + "$ref": "#/components/schemas/FilterGroupV1" }, - "type": "array" - }, - "has_more": { - "type": "boolean" + "minItems": 1 + } + }, + "required": [ + "logic", + "groups" + ], + "description": "A filter condition: one or more groups combined with `logic`." + }, + "FilterGroupV1": { + "type": "object", + "properties": { + "filters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SegmentFilterV1" + } }, - "next_cursor": { - "description": "Pass as `after` to fetch the next page. `null` on the last page.", - "type": [ - "string", - "null" - ] + "conditions": { + "$ref": "#/components/schemas/FilterConditionV1" } }, "required": [ - "data", - "has_more", - "next_cursor" + "filters" ], - "type": "object" + "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." }, "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).", + "type": "object", "properties": { "field": { - "minLength": 1, - "type": "string" + "type": "string", + "minLength": 1 }, "operator": { + "type": "string", "enum": [ "equals", "notEquals", @@ -2430,158 +2563,286 @@ "notTriggered", "notTriggeredWithin", "isMemberOf" - ], - "type": "string" + ] }, + "value": {}, "unit": { + "type": "string", "enum": [ "days", "hours", "minutes" - ], - "type": "string" - }, - "value": {} + ] + } }, "required": [ "field", "operator" ], - "type": "object" + "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)." }, - "SegmentV1": { - "description": "A segment as exposed on the v1 API.", + "CampaignV1Update": { + "type": "object", "properties": { - "condition": { - "anyOf": [ - { - "$ref": "#/components/schemas/FilterConditionV1" - }, - { - "type": "null" - } - ] - }, - "created_at": { - "format": "date-time", - "type": "string" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 }, "description": { - "type": [ - "string", - "null" - ] + "type": "string", + "maxLength": 500 }, - "id": { - "format": "uuid", - "type": "string" + "subject": { + "type": "string", + "minLength": 1 }, - "member_count": { - "type": "integer" + "body": { + "type": "string", + "minLength": 1 }, - "name": { - "type": "string" + "from": { + "type": "string", + "format": "email", + "description": "Sender address. Its domain must be verified for this project." }, - "track_membership": { - "type": "boolean" + "from_name": { + "type": [ + "string", + "null" + ], + "maxLength": 100 }, - "type": { + "reply_to": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "email_category": { + "type": "string", "enum": [ - "DYNAMIC", - "STATIC" + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ] + }, + "audience_type": { + "type": "string", + "enum": [ + "ALL", + "FILTERED", + "SEGMENT", + "LIST" + ] + }, + "audience_condition": { + "$ref": "#/components/schemas/FilterConditionV1" + }, + "segment_id": { + "type": "string", + "format": "uuid" + }, + "list_id": { + "type": "string", + "format": "uuid", + "description": "Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign." + }, + "topic_id": { + "type": [ + "string", + "null" ], - "type": "string" + "format": "uuid", + "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." + } + }, + "description": "Body for PATCH /api/v1/campaigns/{id}. All fields optional." + }, + "CampaignV1Deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" }, - "updated_at": { - "format": "date-time", - "type": "string" + "deleted": { + "type": "boolean", + "enum": [ + true + ] } }, "required": [ "id", - "name", - "description", - "type", - "condition", - "track_membership", - "member_count", - "created_at", - "updated_at" + "deleted" ], - "type": "object" + "description": "Acknowledgement that a campaign was deleted." }, - "SegmentV1Create": { - "description": "Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`.", + "CampaignV1Send": { + "type": "object", "properties": { - "condition": { - "$ref": "#/components/schemas/FilterConditionV1" + "scheduled_for": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately." + } + }, + "description": "Body for POST /api/v1/campaigns/{id}/send." + }, + "CampaignV1Stats": { + "type": "object", + "properties": { + "total_recipients": { + "type": "integer" }, - "description": { - "maxLength": 500, - "type": "string" + "sent": { + "type": "integer" }, - "name": { - "maxLength": 100, - "minLength": 1, - "type": "string" + "delivered": { + "type": "integer" }, - "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" + "opened": { + "type": "integer" }, - "type": { - "default": "DYNAMIC", - "enum": [ - "DYNAMIC", - "STATIC" - ], - "type": "string" + "clicked": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "bounce_rate": { + "type": "number" + }, + "delivery_rate": { + "type": "number" } }, "required": [ - "name" + "total_recipients", + "sent", + "delivered", + "opened", + "clicked", + "bounced", + "open_rate", + "click_rate", + "bounce_rate", + "delivery_rate" ], - "type": "object" + "description": "Materialized delivery and engagement counters for one campaign." }, - "SegmentV1Deleted": { - "description": "Acknowledgement that a segment was deleted.", + "CampaignV1FailureList": { + "type": "object", "properties": { - "deleted": { - "enum": [ - true - ], + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignV1Failure" + } + }, + "has_more": { "type": "boolean" }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + }, + "total": { + "type": "integer", + "description": "Every FAILED row on this campaign, not just this page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor", + "total" + ], + "description": "Cursor-paginated list of a campaign's failed sends." + }, + "CampaignV1Failure": { + "type": "object", + "properties": { "id": { + "type": "string", "format": "uuid", - "type": "string" + "description": "Ledger row id. Pass the last one as `after` to page." + }, + "contact_id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "description": "The recipient the send was for." + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "failed_at": { + "type": "string", + "format": "date-time" } }, "required": [ "id", - "deleted" + "contact_id", + "email", + "reason", + "failed_at" + ], + "description": "A campaign recipient whose send did not complete." + }, + "CampaignV1RetryFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "queued": { + "type": "integer", + "description": "How many FAILED rows the retry walk was started for, counted when it was queued." + } + }, + "required": [ + "id", + "queued" ], - "type": "object" + "description": "Acknowledgement that a retry of a campaign's failed sends began." }, "SegmentV1List": { - "description": "Cursor-paginated list of segments.", + "type": "object", "properties": { "data": { + "type": "array", "items": { "$ref": "#/components/schemas/SegmentV1" - }, - "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" - ] + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." } }, "required": [ @@ -2589,1757 +2850,2055 @@ "has_more", "next_cursor" ], - "type": "object" + "description": "Cursor-paginated list of segments." }, - "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.", + "SegmentV1": { + "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "DYNAMIC", + "STATIC" + ] + }, "condition": { - "$ref": "#/components/schemas/FilterConditionV1" + "anyOf": [ + { + "$ref": "#/components/schemas/FilterConditionV1" + }, + { + "type": "null" + } + ] + }, + "track_membership": { + "type": "boolean" + }, + "member_count": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "type", + "condition", + "track_membership", + "member_count", + "created_at", + "updated_at" + ], + "description": "A segment as exposed on the v1 API." + }, + "SegmentV1Create": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 }, "description": { - "maxLength": 500, - "type": "string" + "type": "string", + "maxLength": 500 + }, + "type": { + "type": "string", + "enum": [ + "DYNAMIC", + "STATIC" + ], + "default": "DYNAMIC" }, + "condition": { + "$ref": "#/components/schemas/FilterConditionV1" + }, + "track_membership": { + "type": "boolean", + "default": false, + "description": "Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition." + } + }, + "required": [ + "name" + ], + "description": "Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`." + }, + "SegmentV1Update": { + "type": "object", + "properties": { "name": { - "maxLength": 100, + "type": "string", "minLength": 1, - "type": "string" + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "condition": { + "$ref": "#/components/schemas/FilterConditionV1" }, "track_membership": { "type": "boolean" } }, - "type": "object" + "description": "Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment." }, - "SendEmail": { - "description": "Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required.", + "SegmentV1Deleted": { + "type": "object", "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": { - "type": "string" - }, - "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" + "id": { + "type": "string", + "format": "uuid" }, - "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" - } + "deleted": { + "type": "boolean", + "enum": [ + true ] } }, "required": [ - "to" + "id", + "deleted" ], - "type": "object" + "description": "Acknowledgement that a segment was deleted." }, - "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`.", + "SegmentContactV1List": { + "type": "object", "properties": { - "emails": { + "data": { + "type": "array", "items": { - "$ref": "#/components/schemas/SendEmailRecipientResult" - }, - "type": "array" + "$ref": "#/components/schemas/SegmentContactV1" + } }, - "timestamp": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." } }, "required": [ - "emails", - "timestamp" + "data", + "has_more", + "next_cursor" ], - "type": "object" + "description": "Cursor-paginated list of the contacts belonging to a segment." }, - "SendEmailRecipientResult": { - "description": "Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient.", + "SegmentContactV1": { + "type": "object", "properties": { - "contact": { - "properties": { - "email": { - "format": "email", - "type": "string" - }, - "id": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "id", - "email" - ], - "type": "object" + "id": { + "type": "string", + "format": "uuid" }, "email": { - "format": "uuid", "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "custom_fields": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "created_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "contact", - "email" + "id", + "email", + "subscribed", + "custom_fields", + "created_at" ], - "type": "object" + "description": "A contact belonging to a segment." }, - "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.", + "WorkflowV1List": { + "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SendEmailData" + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowV1" + } }, - "success": { - "enum": [ - true - ], + "has_more": { "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." } }, "required": [ - "success", - "data" + "data", + "has_more", + "next_cursor" ], - "type": "object" + "description": "Cursor-paginated list of workflows." }, - "SendEmailV1": { - "description": "Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient.", + "WorkflowV1": { + "type": "object", "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" + "id": { + "type": "string", + "format": "uuid" }, - "body": { - "minLength": 1, + "name": { "type": "string" }, - "cc": { - "items": { - "format": "email", - "type": "string" - }, - "type": "array" + "description": { + "type": [ + "string", + "null" + ] }, - "data": { - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", - "type": "object" + "enabled": { + "type": "boolean" }, - "from": { - "anyOf": [ - { - "format": "email", - "type": "string" - }, - { - "properties": { - "email": { - "format": "email", - "type": "string" - }, - "name": { - "pattern": "^[^\\r\\n]*$", - "type": "string" - } - }, - "required": [ - "email" - ], - "type": "object" - } + "trigger_type": { + "type": "string", + "enum": [ + "EVENT", + "MANUAL", + "SCHEDULE" ] }, - "headers": { - "additionalProperties": { - "maxLength": 998, - "pattern": "^[^\\r\\n]*$", - "type": "string" - }, - "type": "object" - }, - "name": { - "type": "string" + "event_name": { + "type": [ + "string", + "null" + ], + "description": "Trigger event for `EVENT` workflows; null for the other trigger types." }, - "reply": { - "format": "email", - "type": "string" + "allow_reentry": { + "type": "boolean" }, - "subject": { - "maxLength": 998, - "minLength": 1, - "pattern": "^[^\\r\\n]*$", - "type": "string" + "max_executions_per_hour": { + "type": [ + "integer", + "null" + ] }, - "subscribed": { - "type": "boolean" + "version": { + "type": "integer", + "description": "Incremented on every structural (step/transition) change." }, - "tags": { - "items": { - "maxLength": 64, - "minLength": 1, - "pattern": "^[a-zA-Z0-9_-]+$", - "type": "string" - }, - "maxItems": 10, - "type": "array" + "step_count": { + "type": "integer", + "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." }, - "template": { - "format": "uuid", - "type": "string" + "created_at": { + "type": "string", + "format": "date-time" }, - "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." + "updated_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "to" + "id", + "name", + "description", + "enabled", + "trigger_type", + "event_name", + "allow_reentry", + "max_executions_per_hour", + "version", + "step_count", + "created_at", + "updated_at" ], - "type": "object" + "description": "An automation workflow as exposed on the v1 API." }, - "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.", + "WorkflowCreateV1": { + "type": "object", "properties": { - "body": { - "description": "HTML body. Merge tags are rendered as on any other send.", + "name": { + "type": "string", "minLength": 1, - "type": "string" + "maxLength": 200 }, - "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" + "description": { + "type": "string", + "maxLength": 1000 }, - "subject": { - "maxLength": 998, + "trigger_type": { + "$ref": "#/components/schemas/WorkflowTriggerTypeV1" + }, + "event_name": { + "type": "string", "minLength": 1, - "type": "string" + "maxLength": 200, + "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." }, - "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" + "interval_ms": { + "type": "integer", + "minimum": 60000, + "maximum": 2592000000, + "description": "For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour." + }, + "enabled": { + "type": "boolean", + "description": "Workflows are created disabled. A workflow can only be enabled once every step is configured." + }, + "allow_reentry": { + "type": "boolean" + }, + "sequence": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSequenceStepV1" + }, + "minItems": 1, + "maxItems": 199, + "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." } }, "required": [ - "subject", - "body" + "name" ], - "type": "object" + "description": "Body for POST /api/v1/workflows." }, - "SuccessEmpty": { - "description": "Bare success envelope with no payload.", - "properties": { - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success" + "WorkflowTriggerTypeV1": { + "type": "string", + "enum": [ + "EVENT", + "MANUAL", + "SCHEDULE" ], - "type": "object" + "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`." }, - "Suppression": { - "description": "A single suppressed-email record.", + "WorkflowSequenceStepV1": { + "type": "object", "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "type": { + "$ref": "#/components/schemas/WorkflowSequenceStepTypeV1" }, - "email": { - "format": "email", - "type": "string" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Human-readable label, e.g. `Day 0: welcome`." }, - "id": { - "format": "uuid", - "type": "string" + "config": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "description": "Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects." }, - "projectId": { + "template_id": { + "type": "string", "format": "uuid", - "type": "string" - }, - "reason": { - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ], - "type": "string" - }, - "source": { - "enum": [ - "SES_WEBHOOK", - "API", - "DASHBOARD" - ], - "type": "string" + "description": "For `SEND_EMAIL`: a template in this project." } }, "required": [ - "id", - "projectId", - "email", - "reason", - "source", - "createdAt" + "type", + "name", + "config" ], - "type": "object" + "description": "One step of a linear workflow sequence." }, - "SuppressionCheckResponse": { - "description": "Result of GET /api/suppression/{email} — whether the address is suppressed.", + "WorkflowSequenceStepTypeV1": { + "type": "string", + "enum": [ + "SEND_EMAIL", + "DELAY", + "WAIT_FOR_EVENT", + "CONDITION", + "EXIT", + "WEBHOOK", + "UPDATE_CONTACT", + "SEND_AT_OPTIMAL_TIME" + ], + "description": "A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server." + }, + "WorkflowUpdateV1": { + "type": "object", "properties": { - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 }, - "reason": { - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ], - "type": "string" + "description": { + "type": "string", + "maxLength": 1000 }, - "source": { - "enum": [ - "SES_WEBHOOK", - "API", - "DASHBOARD" - ], - "type": "string" + "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." + } + ] }, - "suppressed": { + "event_name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "interval_ms": { + "type": "integer", + "minimum": 60000, + "maximum": 2592000000, + "description": "For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour." + }, + "enabled": { + "type": "boolean" + }, + "allow_reentry": { "type": "boolean" + }, + "max_executions_per_hour": { + "type": [ + "integer", + "null" + ], + "exclusiveMinimum": 0, + "description": "Per-workflow start rate cap. `null` removes the cap." + }, + "sequence": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSequenceStepV1" + }, + "minItems": 1, + "maxItems": 199, + "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." + } + }, + "description": "Body for PATCH /api/v1/workflows/{id}. Every field is optional; omitted fields are left unchanged. Changing the trigger while executions are running answers 409." + }, + "WorkflowDeletedV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] } }, "required": [ - "suppressed" + "id", + "deleted" ], - "type": "object" + "description": "Confirmation that a workflow was deleted." }, - "SuppressionListResponse": { - "description": "Cursor-paginated list of suppressions.", + "WorkflowExecutionV1List": { + "type": "object", "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, "data": { + "type": "array", "items": { - "$ref": "#/components/schemas/Suppression" - }, - "type": "array" + "$ref": "#/components/schemas/WorkflowExecutionV1" + } }, - "hasMore": { + "has_more": { "type": "boolean" }, - "nextCursor": { + "next_cursor": { "type": [ "string", "null" - ] - }, - "success": { - "enum": [ - true ], - "type": "boolean" + "description": "Pass as `after` to fetch the next page. `null` on the last page." } }, "required": [ - "success", - "data" + "data", + "has_more", + "next_cursor" ], - "type": "object" + "description": "Cursor-paginated list of workflow executions, newest first." }, - "Template": { - "description": "A reusable email template.", + "WorkflowExecutionV1": { + "type": "object", "properties": { - "body": { - "type": "string" + "id": { + "type": "string", + "format": "uuid" }, - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "workflow_id": { + "type": "string", + "format": "uuid" }, - "description": { + "contact_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "RUNNING", + "WAITING", + "COMPLETED", + "EXITED", + "FAILED", + "CANCELLED" + ] + }, + "current_step_id": { "type": [ "string", "null" - ] - }, - "from": { - "format": "email", - "type": "string" + ], + "format": "uuid" }, - "fromName": { + "exit_reason": { "type": [ "string", "null" ] }, - "id": { - "format": "uuid", - "type": "string" - }, - "name": { - "type": "string" - }, - "projectId": { - "format": "uuid", - "type": "string" + "started_at": { + "type": "string", + "format": "date-time" }, - "replyTo": { - "format": "email", + "completed_at": { "type": [ "string", "null" - ] - }, - "subject": { - "type": "string" - }, - "type": { - "enum": [ - "MARKETING", - "TRANSACTIONAL", - "HEADLESS" ], - "type": "string" - }, - "updatedAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "format": "date-time" } }, "required": [ "id", - "projectId", - "name", - "subject", - "body", - "from", - "type", - "createdAt", - "updatedAt" + "workflow_id", + "contact_id", + "status", + "current_step_id", + "exit_reason", + "started_at", + "completed_at" ], - "type": "object" + "description": "One contact's run through a workflow." }, - "TemplateListResponse": { - "description": "Cursor-paginated list of templates.", + "WorkflowExecutionStartV1": { + "type": "object", "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" + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Contact to enter the workflow. Must belong to this project." }, - "success": { - "enum": [ - true - ], - "type": "boolean" + "context": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "description": "Extra variables merged into the contact's data for this run." } }, "required": [ - "success", - "data" + "contact_id" ], - "type": "object" + "description": "Body for POST /api/v1/workflows/{id}/executions." }, - "TrackEvent": { - "description": "Body for POST /api/track — record a custom event for a contact.", + "WorkflowStatsV1": { + "type": "object", "properties": { - "data": { - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", - "type": "object" - }, - "email": { - "format": "email", - "type": "string" + "workflow_id": { + "type": "string", + "format": "uuid" }, - "event": { - "minLength": 1, + "name": { "type": "string" }, - "subscribed": { + "enabled": { "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" + }, + "trigger_type": { + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowTriggerTypeV1" }, - "timestamp": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + { + "description": "What starts a workflow. `EVENT`: a custom event you record. `MANUAL`: only an explicit API call. `SCHEDULE`: a fixed repeating interval." } + ] + }, + "step_count": { + "type": "integer", + "description": "Steps in the workflow's graph, trigger step included." + }, + "total": { + "type": "integer" + }, + "by_status": { + "type": "object", + "additionalProperties": { + "type": "integer" }, - "required": [ - "contact", - "event", - "timestamp" - ], - "type": "object" + "description": "Execution counts keyed by status; a status with no executions is absent." }, - "success": { - "enum": [ - true + "completion_rate": { + "type": [ + "number", + "null" ], - "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" + "description": "Completed ÷ finished executions (0–1). Null until at least one execution has finished." }, - "subscribed": { - "type": "boolean" - } - }, - "type": "object" + "avg_duration_ms": { + "type": [ + "number", + "null" + ] + }, + "emails": { + "type": "object", + "properties": { + "sent": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + } + }, + "required": [ + "sent", + "opened", + "clicked" + ] + }, + "conversions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "event_name": { + "type": "string" + }, + "count": { + "type": "integer" + } + }, + "required": [ + "goal_id", + "name", + "event_name", + "count" + ] + } + } + }, + "required": [ + "workflow_id", + "name", + "enabled", + "trigger_type", + "step_count", + "total", + "by_status", + "completion_rate", + "avg_duration_ms", + "emails", + "conversions" + ], + "description": "Execution, email and conversion totals for one workflow." }, - "UpdateTemplate": { - "description": "Body for PATCH /api/templates/{id}.", + "WorkflowGraphV1": { + "type": "object", "properties": { - "body": { - "minLength": 1, - "type": "string" + "workflow_id": { + "type": "string", + "format": "uuid" }, - "description": { - "maxLength": 500, - "type": "string" + "version": { + "type": "integer", + "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." }, - "from": { - "format": "email", - "type": "string" + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowStepReadV1" + } }, - "fromName": { - "maxLength": 100, - "type": [ - "string", - "null" - ] + "transitions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowTransitionV1" + } + } + }, + "required": [ + "workflow_id", + "version", + "steps", + "transitions" + ], + "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." + }, + "WorkflowStepReadV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "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." }, "name": { - "maxLength": 100, + "type": "string", "minLength": 1, - "type": "string" + "maxLength": 100 }, - "replyTo": { - "format": "email", + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" + }, + "template_id": { "type": [ "string", "null" - ] - }, - "subject": { - "minLength": 1, - "type": "string" + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." }, "type": { + "type": "string", "enum": [ - "TRANSACTIONAL", - "MARKETING", - "HEADLESS" + "TRIGGER", + "SEND_EMAIL", + "DELAY", + "WAIT_FOR_EVENT", + "CONDITION", + "EXIT", + "WEBHOOK", + "UPDATE_CONTACT", + "SEND_AT_OPTIMAL_TIME" + ] + }, + "config": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "description": "The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase." + } + }, + "required": [ + "id", + "name", + "position", + "type", + "config" + ], + "description": "One node of a workflow graph, as read." + }, + "WorkflowStepPositionV1": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "description": "Where this step sits on the editor canvas." + }, + "WorkflowTransitionV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Caller-chosen on a write, exactly like a step id." + }, + "from_step_id": { + "type": "string", + "format": "uuid" + }, + "to_step_id": { + "type": "string", + "format": "uuid" + }, + "condition": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" ], - "type": "string" + "additionalProperties": {}, + "description": "Null to always follow this edge. From a `CONDITION` step, `{ \"branch\": \"yes\" }`, `{ \"branch\": \"no\" }`, or `{ \"branch\": \"\" }` in the multi form." + }, + "priority": { + "type": "integer", + "minimum": 0, + "description": "Evaluation order among the edges leaving one step; lowest first." } }, - "type": "object" + "required": [ + "id", + "from_step_id", + "to_step_id", + "condition", + "priority" + ], + "description": "One directed edge between two steps." }, - "UpdateWebhook": { - "description": "Body for PATCH /api/webhooks/{id}.", + "WorkflowGraphReplaceV1": { + "type": "object", "properties": { - "eventTypes": { + "steps": { + "type": "array", "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" + "$ref": "#/components/schemas/WorkflowStepV1" }, "minItems": 1, - "type": "array" + "maxItems": 200, + "description": "The complete step set. Exactly one must be a `TRIGGER`." }, - "status": { - "enum": [ - "ACTIVE", - "PAUSED", - "DISABLED" - ], - "type": "string" + "transitions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowTransitionV1" + }, + "maxItems": 400, + "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." + } + }, + "required": [ + "steps", + "transitions" + ], + "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." + }, + "WorkflowStepV1": { + "oneOf": [ + { + "$ref": "#/components/schemas/WorkflowTriggerStepV1" }, - "url": { - "format": "uri", - "type": "string" + { + "$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" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "TRIGGER": "#/components/schemas/WorkflowTriggerStepV1", + "SEND_EMAIL": "#/components/schemas/WorkflowSendEmailStepV1", + "DELAY": "#/components/schemas/WorkflowDelayStepV1", + "WAIT_FOR_EVENT": "#/components/schemas/WorkflowWaitForEventStepV1", + "CONDITION": "#/components/schemas/WorkflowConditionStepV1", + "EXIT": "#/components/schemas/WorkflowExitStepV1", + "WEBHOOK": "#/components/schemas/WorkflowWebhookStepV1", + "UPDATE_CONTACT": "#/components/schemas/WorkflowUpdateContactStepV1", + "SEND_AT_OPTIMAL_TIME": "#/components/schemas/WorkflowSendAtOptimalTimeStepV1" } }, - "type": "object" + "description": "One node of a workflow graph." }, - "UsageV1": { - "description": "Current email usage against the limits that are actually enforced.", + "WorkflowTriggerStepV1": { + "type": "object", "properties": { - "daily": { + "id": { + "type": "string", + "format": "uuid", + "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." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" + }, + "template_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + }, + "type": { + "type": "string", + "enum": [ + "TRIGGER" + ] + }, + "config": { + "type": "object", "properties": { - "emails_sent": { - "description": "Today's sends. Null when the counter could not be read.", - "type": [ - "integer", - "null" - ] - }, - "limit": { - "type": "integer" + "eventName": { + "type": "string", + "minLength": 1, + "maxLength": 200 }, - "trust_tier": { - "enum": [ - "NEW", - "ESTABLISHED", - "TRUSTED" - ], - "type": "string" + "intervalMs": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "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" + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + } + }, + "required": [ + "id", + "name", + "position", + "type", + "config" + ], + "description": "The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`." + }, + "WorkflowSendEmailStepV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "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." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" + }, + "template_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + }, + "type": { + "type": "string", + "enum": [ + "SEND_EMAIL" + ] + }, + "config": { + "type": "object", + "properties": { + "templateId": { + "type": "string", + "format": "uuid" + }, + "subject": { + "type": "string", + "maxLength": 1000 + }, + "body": { + "type": "string" + }, + "recipient": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "CONTACT", + "CUSTOM" + ] }, - "workflow": { - "properties": { - "emails_sent": { - "type": "integer" - }, - "limit": { - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "emails_sent", - "limit" - ], - "type": "object" + "customEmail": { + "type": "string", + "format": "email" } }, "required": [ - "transactional", - "campaign", - "workflow", - "inbound" + "type" ], - "type": "object" - }, - "emails_sent": { - "type": "integer" - }, - "limit": { - "description": "Monthly cap on the total. Null when per-category limits govern instead.", - "type": [ - "integer", - "null" - ] + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, - "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" + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, "required": [ - "plan", - "monthly", - "daily" + "id", + "name", + "position", + "type", + "config" ], - "type": "object" + "description": "Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`." }, - "VerifyEmail": { - "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc.", + "WorkflowDelayStepV1": { + "type": "object", "properties": { - "email": { - "format": "email", - "type": "string" + "id": { + "type": "string", + "format": "uuid", + "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." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" + }, + "template_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + }, + "type": { + "type": "string", + "enum": [ + "DELAY" + ] + }, + "config": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "unit": { + "type": "string", + "enum": [ + "minutes", + "hours", + "days" + ] + } + }, + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, "required": [ - "email" + "id", + "name", + "position", + "type", + "config" ], - "type": "object" + "description": "Pauses the run for `amount` × `unit`, up to 365 days." }, - "VerifyEmailResponse": { - "description": "Response from POST /api/verify — outcome of the syntax/MX/disposable check.", + "WorkflowWaitForEventStepV1": { + "type": "object", "properties": { - "data": { - "additionalProperties": {}, + "id": { + "type": "string", + "format": "uuid", + "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." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" + }, + "template_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + }, + "type": { + "type": "string", + "enum": [ + "WAIT_FOR_EVENT" + ] + }, + "config": { + "type": "object", "properties": { - "email": { - "format": "email", - "type": "string" + "eventName": { + "type": "string", + "minLength": 1, + "maxLength": 200 }, - "reason": { - "type": "string" - }, - "valid": { - "type": "boolean" + "timeout": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 31536000 } }, - "required": [ - "email", - "valid" - ], - "type": "object" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, "required": [ - "success", - "data" + "id", + "name", + "position", + "type", + "config" ], - "type": "object" + "description": "Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass." }, - "Webhook": { - "description": "A user-managed outbound webhook.", + "WorkflowConditionStepV1": { + "type": "object", "properties": { - "consecutiveFailures": { - "type": "integer" + "id": { + "type": "string", + "format": "uuid", + "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." }, - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 }, - "disabledAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" + }, + "template_id": { "type": [ "string", "null" + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + }, + "type": { + "type": "string", + "enum": [ + "CONDITION" ] }, - "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" + "config": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "multi" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "operator": { + "type": "string", + "enum": [ + "equals", + "notEquals", + "contains", + "notContains", + "greaterThan", + "lessThan", + "greaterThanOrEqual", + "lessThanOrEqual", + "exists", + "notExists" + ] + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "branches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "operator": { + "type": "string", + "enum": [ + "equals", + "notEquals", + "contains", + "notContains", + "greaterThan", + "lessThan", + "greaterThanOrEqual", + "lessThanOrEqual", + "exists", + "notExists" + ] + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + }, + "required": [ + "id", + "name", + "operator" + ], + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + }, + "maxItems": 20 + } }, - "type": "array" - }, + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + } + }, + "required": [ + "id", + "name", + "position", + "type", + "config" + ], + "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." + }, + "WorkflowExitStepV1": { + "type": "object", + "properties": { "id": { + "type": "string", "format": "uuid", - "type": "string" + "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." }, - "lastFour": { - "type": "string" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 }, - "projectId": { - "format": "uuid", - "type": "string" + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" }, - "status": { - "enum": [ - "ACTIVE", - "PAUSED", - "DISABLED" + "template_id": { + "type": [ + "string", + "null" ], - "type": "string" + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." }, - "updatedAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" + "type": { + "type": "string", + "enum": [ + "EXIT" + ] }, - "url": { - "format": "uri", - "type": "string" + "config": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 200 + } + }, + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, "required": [ "id", - "projectId", - "url", - "eventTypes", - "status", - "consecutiveFailures", - "createdAt", - "updatedAt" + "name", + "position", + "type", + "config" ], - "type": "object" + "description": "Ends the run early and stamps `exit_reason`." }, - "WebhookCall": { - "description": "An attempted webhook delivery.", + "WorkflowWebhookStepV1": { + "type": "object", "properties": { - "attempt": { - "type": "integer" - }, - "createdAt": { - "description": "ISO 8601 datetime string", - "format": "date-time", - "type": "string" - }, - "eventType": { - "type": "string" - }, "id": { + "type": "string", "format": "uuid", - "type": "string" + "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." }, - "payload": { - "additionalProperties": {}, - "type": "object" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 }, - "responseBody": { - "type": [ - "string", - "null" - ] + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" }, - "responseStatus": { + "template_id": { "type": [ - "integer", + "string", "null" - ] + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." }, - "status": { + "type": { + "type": "string", "enum": [ - "PENDING", - "SUCCESS", - "FAILED" - ], - "type": "string" + "WEBHOOK" + ] }, - "webhookId": { - "format": "uuid", - "type": "string" + "config": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + }, + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, "required": [ "id", - "webhookId", - "eventType", - "payload", - "status", - "attempt", - "createdAt" + "name", + "position", + "type", + "config" ], - "type": "object" + "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." }, - "WebhookCallsListResponse": { - "description": "Cursor-paginated list of recent calls for a single webhook.", + "WorkflowUpdateContactStepV1": { + "type": "object", "properties": { - "cursor": { - "type": [ - "string", - "null" - ] + "id": { + "type": "string", + "format": "uuid", + "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." }, - "data": { - "items": { - "$ref": "#/components/schemas/WebhookCall" - }, - "type": "array" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 }, - "hasMore": { - "type": "boolean" + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" }, - "nextCursor": { + "template_id": { "type": [ "string", "null" - ] + ], + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." }, - "success": { + "type": { + "type": "string", "enum": [ - true - ], - "type": "boolean" + "UPDATE_CONTACT" + ] + }, + "config": { + "type": "object", + "properties": { + "updates": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + }, + "subscribed": { + "type": "boolean" + } + }, + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, "required": [ - "success", - "data" + "id", + "name", + "position", + "type", + "config" ], - "type": "object" + "description": "Writes `updates` onto the contact, and optionally flips `subscribed`." }, - "WebhookCreateResponse": { - "description": "Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely.", + "WorkflowSendAtOptimalTimeStepV1": { + "type": "object", "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." + "id": { + "type": "string", + "format": "uuid", + "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." }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" - }, - "WebhookGetResponse": { - "description": "Single webhook (no secret).", - "properties": { - "data": { - "$ref": "#/components/schemas/Webhook" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 }, - "success": { - "enum": [ - true + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" + }, + "template_id": { + "type": [ + "string", + "null" ], - "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" + "format": "uuid", + "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." }, - "success": { + "type": { + "type": "string", "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": { + "SEND_AT_OPTIMAL_TIME" + ] + }, + "config": { + "type": "object", "properties": { - "id": { - "format": "uuid", - "type": "string" + "templateId": { + "type": "string", + "format": "uuid" }, - "secret": { - "description": "New plaintext shared secret.", - "type": "string" + "fallbackHour": { + "type": "integer", + "minimum": 0, + "maximum": 23 + }, + "maxDelayHours": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 168 } }, - "required": [ - "id", - "secret" - ], - "type": "object" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } } }, "required": [ - "success", - "data" + "id", + "name", + "position", + "type", + "config" ], - "type": "object" + "description": "Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`." }, - "WorkflowCreateV1": { - "description": "Body for POST /api/v1/workflows.", + "WorkflowCloneV1": { + "type": "object", "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`.", - "maxLength": 200, - "minLength": 1, - "type": "string" - }, "name": { - "maxLength": 200, + "type": "string", "minLength": 1, - "type": "string" - } - }, - "required": [ - "name", - "event_name" - ], - "type": "object" - }, - "WorkflowDeletedV1": { - "description": "Confirmation that a workflow was deleted.", - "properties": { - "deleted": { - "enum": [ - true - ], - "type": "boolean" - }, - "id": { - "format": "uuid", - "type": "string" + "maxLength": 200, + "description": "Name for the copy. Defaults to `Copy of `." } }, - "required": [ - "id", - "deleted" - ], - "type": "object" + "description": "Body for `POST /api/v1/workflows/{id}/clone`." }, - "WorkflowExecutionStartV1": { - "description": "Body for POST /api/v1/workflows/{id}/executions.", + "WorkflowStateChangeV1": { + "type": "object", "properties": { - "contact_id": { - "description": "Contact to enter the workflow. Must belong to this project.", - "format": "uuid", - "type": "string" + "workflow": { + "$ref": "#/components/schemas/WorkflowV1" }, - "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" + "cancelled_executions": { + "type": "integer", + "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)." } }, "required": [ - "contact_id" + "workflow", + "cancelled_executions" ], - "type": "object" + "description": "The workflow after a pause or resume, with the number of runs the call stopped." }, - "WorkflowExecutionV1": { - "description": "One contact's run through a workflow.", + "EmailV1": { + "type": "object", "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": { + "type": "string", "format": "uuid", - "type": "string" - }, - "started_at": { - "format": "date-time", - "type": "string" + "description": "The Email row this send created. Quote it in support requests." }, "status": { + "type": "string", "enum": [ - "RUNNING", - "WAITING", - "COMPLETED", - "EXITED", + "PENDING", + "SENDING", + "SENT", + "DELIVERED", + "RECEIVED", + "BOUNCED", "FAILED", + "REJECTED", + "RENDERING_FAILURE", + "DELIVERY_DELAY", "CANCELLED" ], - "type": "string" + "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." }, - "workflow_id": { - "format": "uuid", - "type": "string" + "to": { + "type": "string", + "format": "email", + "description": "The recipient the message was queued for." + }, + "from": { + "type": "string", + "format": "email", + "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." } }, "required": [ "id", - "workflow_id", - "contact_id", "status", - "current_step_id", - "exit_reason", - "started_at", - "completed_at" + "to", + "from" ], - "type": "object" + "description": "Receipt for a single transactional send." }, - "WorkflowExecutionV1List": { - "description": "Cursor-paginated list of workflow executions, newest first.", + "SendEmailV1": { + "type": "object", "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/WorkflowExecutionV1" - }, - "type": "array" + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 998, + "pattern": "^[^\\r\\n]*$" }, - "has_more": { - "type": "boolean" + "body": { + "type": "string", + "minLength": 1 }, - "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" - }, - "WorkflowStatsV1": { - "description": "Execution, email and conversion totals for one workflow.", - "properties": { - "avg_duration_ms": { - "type": [ - "number", - "null" - ] + "template": { + "type": "string", + "format": "uuid" }, - "by_status": { - "additionalProperties": { - "type": "integer" - }, - "description": "Execution counts keyed by status; a status with no executions is absent.", - "type": "object" + "subscribed": { + "type": "boolean" }, - "completion_rate": { - "description": "Completed ÷ finished executions (0–1). Null until at least one execution has finished.", - "type": [ - "number", - "null" - ] + "name": { + "type": "string" }, - "conversions": { + "from": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[^\\r\\n]*$" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + } + ] + }, + "reply": { + "type": "string", + "format": "email" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 998, + "pattern": "^[^\\r\\n]*$" + } + }, + "data": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "attachments": { + "type": "array", "items": { + "type": "object", "properties": { - "count": { - "type": "integer" + "filename": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[^\\r\\n\"]+$" }, - "event_name": { - "type": "string" + "content": { + "type": "string", + "minLength": 1 }, - "goal_id": { - "format": "uuid", - "type": "string" + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255 }, - "name": { - "type": "string" + "contentId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[^<>\\r\\n]+$" + }, + "disposition": { + "type": "string", + "enum": [ + "attachment", + "inline" + ], + "default": "attachment" } }, "required": [ - "goal_id", - "name", - "event_name", - "count" - ], - "type": "object" + "filename", + "content", + "contentType" + ] }, - "type": "array" + "maxItems": 10 }, - "emails": { - "properties": { - "clicked": { - "type": "integer" - }, - "opened": { - "type": "integer" - }, - "sent": { - "type": "integer" - } + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9_-]+$" }, - "required": [ - "sent", - "opened", - "clicked" - ], - "type": "object" + "maxItems": 10 }, - "total": { - "type": "integer" + "cc": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } }, - "workflow_id": { - "format": "uuid", - "type": "string" + "bcc": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + }, + "to": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + } + ], + "description": "The single recipient. Use `cc`/`bcc` to copy others on the same message." } }, "required": [ - "workflow_id", - "total", - "by_status", - "completion_rate", - "avg_duration_ms", - "emails", - "conversions" + "to" ], - "type": "object" + "description": "Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient." }, - "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.", + "EmailTestV1": { + "type": "object", "properties": { - "allow_reentry": { - "type": "boolean" - }, - "description": { - "maxLength": 1000, - "type": "string" + "id": { + "type": "string", + "format": "uuid", + "description": "The Email row this send created." }, - "enabled": { - "type": "boolean" + "status": { + "type": "string", + "enum": [ + "PENDING", + "SENDING", + "SENT", + "DELIVERED", + "RECEIVED", + "BOUNCED", + "FAILED", + "REJECTED", + "RENDERING_FAILURE", + "DELIVERY_DELAY", + "CANCELLED" + ], + "description": "Delivery status at the moment of the response — `PENDING` for a send still queued." }, - "event_name": { - "maxLength": 200, - "minLength": 1, - "type": "string" + "to": { + "type": "string", + "format": "email", + "description": "The recipient the message was queued for." }, - "max_executions_per_hour": { - "description": "Per-workflow start rate cap. `null` removes the cap.", - "exclusiveMinimum": 0, - "type": [ - "integer", - "null" - ] + "from": { + "type": "string", + "format": "email", + "description": "This project's sandbox sender — resolved server-side, never from the body." }, - "name": { - "maxLength": 200, - "minLength": 1, - "type": "string" + "sandbox": { + "type": "boolean", + "enum": [ + true + ], + "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." } }, - "type": "object" + "required": [ + "id", + "status", + "to", + "from", + "sandbox" + ], + "description": "Receipt for a sandbox test send." }, - "WorkflowV1": { - "description": "An automation workflow as exposed on the v1 API.", + "SendTestEmailV1": { + "type": "object", "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" + "to": { + "type": "string", + "format": "email", + "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." }, - "trigger_type": { - "enum": [ - "EVENT", - "MANUAL", - "SCHEDULE" - ], - "type": "string" + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 998 }, - "updated_at": { - "format": "date-time", - "type": "string" + "body": { + "type": "string", + "minLength": 1, + "description": "HTML body. Merge tags are rendered as on any other send." }, - "version": { - "description": "Incremented on every structural (step/transition) change.", - "type": "integer" + "from": { + "type": "string", + "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." } }, "required": [ - "id", - "name", - "description", - "enabled", - "trigger_type", - "event_name", - "allow_reentry", - "max_executions_per_hour", - "version", - "created_at", - "updated_at" + "subject", + "body" ], - "type": "object" + "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." }, - "WorkflowV1List": { - "description": "Cursor-paginated list of workflows.", + "ContactV1List": { + "type": "object", "properties": { "data": { + "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowV1" - }, - "type": "array" + "$ref": "#/components/schemas/ContactV1" + } }, "has_more": { "type": "boolean" }, "next_cursor": { - "description": "Pass as `after` to fetch the next page. `null` on the last page.", "type": [ "string", "null" - ] + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." } }, "required": [ @@ -4347,198 +4906,10099 @@ "has_more", "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" + "description": "Cursor-paginated list of contacts." }, - "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" + "ContactV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "custom_fields": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "email", + "subscribed", + "custom_fields", + "created_at", + "updated_at" + ], + "description": "A contact as exposed on the v1 API." + }, + "ContactV1Create": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "subscribed": { + "type": "boolean", + "default": true + }, + "custom_fields": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`." + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/v1/contacts." + }, + "ContactV1Update": { + "type": "object", + "properties": { + "subscribed": { + "type": "boolean" + }, + "custom_fields": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + }, + "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." + }, + "ContactV1Deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Acknowledgement that a contact was deleted." + }, + "ListSubscribe": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "data": { + "type": "object", + "additionalProperties": {}, + "description": "Custom fields to upsert onto the contact as part of subscribing." + }, + "allowResubscribe": { + "type": "boolean", + "default": false, + "description": "Permission to reverse an earlier opt-out. When the email already has an UNSUBSCRIBED membership on this list, the call fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED unless this is `true`. Send `true` only when the contact is acting for themselves — a public subscribe form they submitted is affirmative consent — never for an operator-initiated add." + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/lists/{id}/subscribe." + }, + "ListUnsubscribe": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/lists/{id}/unsubscribe." + }, + "ListV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of subscriber lists." + }, + "ListV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "double_opt_in": { + "type": "boolean" + }, + "confirmation_template_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "redirect_url": { + "type": [ + "string", + "null" + ] + }, + "member_count": { + "type": "integer", + "description": "Memberships in ANY status, including PENDING and UNSUBSCRIBED ones." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "double_opt_in", + "confirmation_template_id", + "redirect_url", + "member_count", + "created_at", + "updated_at" + ], + "description": "A subscriber list as exposed on the v1 API." + }, + "ListV1Create": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 500 + }, + "double_opt_in": { + "type": "boolean", + "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." + }, + "confirmation_template_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "redirect_url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Where a confirmed contact is sent after following the confirmation link." + } + }, + "required": [ + "name" + ], + "description": "Body for POST /api/v1/lists." + }, + "ListV1Update": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 500 + }, + "double_opt_in": { + "type": "boolean" + }, + "confirmation_template_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "redirect_url": { + "type": [ + "string", + "null" + ], + "format": "uri" + } + }, + "description": "Body for PATCH /api/v1/lists/{id}." + }, + "ListV1Deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Acknowledgement that a list was deleted." + }, + "AddDomainBody": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "format": "uuid" + }, + "domain": { + "type": "string", + "minLength": 3, + "maxLength": 253 + }, + "region": { + "type": "string", + "enum": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ], + "description": "Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region." + }, + "stream": { + "$ref": "#/components/schemas/SendingStream" + }, + "streamDefault": { + "type": "boolean", + "description": "Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it." + } + }, + "required": [ + "domain" + ], + "description": "Body for POST /api/domains. `projectId` is optional for API-key auth (derived from key) and required for session auth. `region` pins the SES region; on the first domain it locks the project, after that it must match the project's region. `stream` assigns the identity to transactional or marketing traffic at creation; omit it to leave the identity serving both." + }, + "SendingStream": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING" + ], + "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." + }, + "AssignDomainStream": { + "type": "object", + "properties": { + "stream": { + "type": [ + "string", + "null" + ], + "enum": [ + "TRANSACTIONAL", + "MARKETING", + null + ], + "description": "Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address." + }, + "streamDefault": { + "type": "boolean", + "description": "Make this the project's default identity for its stream, demoting whichever held it." + }, + "defaultFromAddress": { + "type": [ + "string", + "null" + ], + "format": "email", + "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." + } + }, + "description": "Body for PATCH /api/domains/{id}." + }, + "DomainV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DomainV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of sending domains." + }, + "DomainV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "domain": { + "type": "string" + }, + "verified": { + "type": "boolean" + }, + "region": { + "type": [ + "string", + "null" + ] + }, + "stream": { + "allOf": [ + { + "$ref": "#/components/schemas/SendingStream" + }, + { + "type": [ + "string", + "null" + ], + "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." + } + ] + }, + "stream_default": { + "type": "boolean" + }, + "default_from_address": { + "type": [ + "string", + "null" + ] + }, + "mail_from_domain": { + "type": [ + "string", + "null" + ] + }, + "mail_from_domain_status": { + "type": [ + "string", + "null" + ], + "description": "SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address." + }, + "dkim_verified": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "domain", + "verified", + "region", + "stream", + "stream_default", + "default_from_address", + "mail_from_domain", + "mail_from_domain_status", + "dkim_verified", + "created_at", + "updated_at" + ], + "description": "A sending domain as exposed on the v1 API." + }, + "DomainV1Create": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "minLength": 3, + "maxLength": 253 + }, + "region": { + "type": "string", + "enum": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ], + "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed." + }, + "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." + } + ] + }, + "stream_default": { + "type": "boolean", + "description": "Make this the project's default identity for `stream`. Requires `stream`." + } + }, + "required": [ + "domain" + ], + "description": "Body for POST /api/v1/domains." + }, + "DomainV1Deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Acknowledgement that a sending domain was removed." + }, + "CreateTemplate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "format": "email" + }, + "fromName": { + "type": [ + "string", + "null" + ], + "maxLength": 100 + }, + "replyTo": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "emailCategory": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ], + "default": "MARKETING" + } + }, + "required": [ + "name", + "subject", + "body", + "from" + ], + "description": "Body for POST /api/templates." + }, + "UpdateTemplate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "format": "email" + }, + "fromName": { + "type": [ + "string", + "null" + ], + "maxLength": 100 + }, + "replyTo": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "emailCategory": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ] + } + }, + "description": "Body for PATCH /api/templates/{id}." + }, + "TemplateV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TemplateV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of templates." + }, + "TemplateV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "subject": { + "type": "string" + }, + "body": { + "type": "string" + }, + "from": { + "type": "string" + }, + "from_name": { + "type": [ + "string", + "null" + ] + }, + "reply_to": { + "type": [ + "string", + "null" + ] + }, + "email_category": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ] + }, + "version": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "subject", + "body", + "from", + "from_name", + "reply_to", + "email_category", + "version", + "created_at", + "updated_at" + ], + "description": "An email template as exposed on the v1 API." + }, + "TemplateV1Create": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 500 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "format": "email", + "description": "Sender address. Its domain must be verified for this project." + }, + "from_name": { + "type": [ + "string", + "null" + ], + "maxLength": 100 + }, + "reply_to": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "email_category": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ], + "default": "MARKETING" + } + }, + "required": [ + "name", + "subject", + "body", + "from" + ], + "description": "Body for POST /api/v1/templates." + }, + "TemplateV1Update": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 500 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "format": "email" + }, + "from_name": { + "type": [ + "string", + "null" + ], + "maxLength": 100 + }, + "reply_to": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "email_category": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ] + } + }, + "description": "Body for PATCH /api/v1/templates/{id}." + }, + "TemplateV1Deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Acknowledgement that a template was deleted." + }, + "CreateSnippet": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][\\da-z_-]{0,63}$/i" + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 500 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 20000 + } + }, + "required": [ + "name", + "body" + ], + "description": "Body for POST /api/snippets." + }, + "UpdateSnippet": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][\\da-z_-]{0,63}$/i" + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 500 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 20000 + } + }, + "description": "Body for PATCH /api/snippets/{id}." + }, + "CreateWebhook": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ] + }, + "minItems": 1 + } + }, + "required": [ + "url", + "eventTypes" + ], + "description": "Body for POST /api/webhooks — register a user webhook for one or more events." + }, + "UpdateWebhook": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ] + }, + "minItems": 1 + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "PAUSED", + "DISABLED" + ] + } + }, + "description": "Body for PATCH /api/webhooks/{id}." + }, + "WebhookV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of webhook endpoints." + }, + "WebhookV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "url": { + "type": "string" + }, + "event_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "PAUSED", + "DISABLED" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "url", + "event_types", + "status", + "created_at", + "updated_at" + ], + "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." + }, + "WebhookV1Created": { + "type": "object", + "properties": { + "webhook": { + "$ref": "#/components/schemas/WebhookV1" + }, + "secret": { + "type": "string", + "description": "The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again." + } + }, + "required": [ + "webhook", + "secret" + ], + "description": "A newly created webhook and its one-time signing secret." + }, + "WebhookV1Create": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "event_types": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ] + }, + "minItems": 1 + } + }, + "required": [ + "url", + "event_types" + ], + "description": "Body for POST /api/v1/webhooks." + }, + "WebhookV1Update": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "event_types": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email.sent", + "email.delivered", + "email.opened", + "email.clicked", + "email.bounced", + "email.complained", + "email.failed", + "contact.created", + "contact.unsubscribed", + "contacts.bulk_created" + ] + }, + "minItems": 1 + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "PAUSED", + "DISABLED" + ] + } + }, + "description": "Body for PATCH /api/v1/webhooks/{id}." + }, + "WebhookV1Deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Acknowledgement that a webhook was deleted." + }, + "WebhookV1SecretRotated": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again." + }, + "previous_secret_expires_at": { + "type": "string", + "format": "date-time", + "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." + } + }, + "required": [ + "secret", + "previous_secret_expires_at" + ], + "description": "A freshly rotated signing secret, and the moment the outgoing one stops verifying." + }, + "CreateApiKeyBody": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "legacyGrantPreset": { + "type": "string", + "enum": [ + "FULL", + "SENDING_ONLY" + ] + }, + "mode": { + "type": "string", + "enum": [ + "LIVE", + "TEST" + ], + "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." + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "emails:send", + "emails:read", + "contacts:read", + "contacts:write", + "campaigns:read", + "campaigns:write", + "segments:read", + "segments:write", + "workflows:read", + "workflows:write", + "templates:read", + "templates:write", + "domains:read", + "domains:write", + "webhooks:read", + "webhooks:write", + "suppression:read", + "suppression:write", + "analytics:read", + "usage:read", + "events:read", + "events:write", + "projects:read", + "projects:write", + "api-keys:read", + "api-keys:write", + "campaigns:send", + "mailboxes:read", + "mailboxes:write", + "emails:test", + "deliverability:read", + "mailboxes:send", + "validation:read", + "validation:write", + "topics:read", + "topics:write", + "lists:read", + "lists:write" + ] + }, + "description": "The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`." + }, + "domainId": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + "required": [ + "name" + ] + }, + "AddSuppression": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "reason": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "default": "MANUAL" + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/suppression — manually add an email to the suppression list." + }, + "SuppressionV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SuppressionV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of suppressed addresses." + }, + "SuppressionV1": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ] + }, + "source": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "email", + "reason", + "source", + "created_at" + ], + "description": "A suppressed address as exposed on the v1 API." + }, + "SuppressionV1Create": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "reason": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "default": "MANUAL" + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/v1/suppressions." + }, + "SuppressionV1Deleted": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "email", + "deleted" + ], + "description": "Acknowledgement that an address was un-suppressed." + }, + "TrackEvent": { + "type": "object", + "properties": { + "event": { + "type": "string", + "minLength": 1 + }, + "email": { + "type": "string", + "format": "email" + }, + "subscribed": { + "type": "boolean" + }, + "data": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + } + }, + "required": [ + "event", + "email" + ], + "description": "Body for POST /api/track — record a custom event for a contact." + }, + "EventV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated list of events, newest first." + }, + "EventV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "contact_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "email_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "payload": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The payload recorded with the event, or null." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "contact_id", + "email_id", + "payload", + "created_at" + ], + "description": "A recorded custom event." + }, + "EventTrackV1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Event name, e.g. `user.signup`." + }, + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event." + }, + "payload": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + }, + "description": "Arbitrary event payload." + } + }, + "required": [ + "name" + ], + "description": "Body for POST /api/v1/events." + }, + "EventNamesV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "data" + ], + "description": "Every distinct event name in the project, most frequent first." + }, + "EventStatsV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "integer" + } + }, + "required": [ + "name", + "count" + ] + } + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "data", + "window" + ], + "description": "Per-name event counts over the applied window." + }, + "AnalyticsWindowV1": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "from", + "to" + ], + "description": "The time range this response was computed over, after the 90-day clamp." + }, + "AnalyticsTimeseriesV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "emails": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opens": { + "type": "integer" + }, + "clicks": { + "type": "integer" + }, + "bounces": { + "type": "integer" + } + }, + "required": [ + "date", + "emails", + "delivered", + "opens", + "clicks", + "bounces" + ] + } + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "data", + "window" + ], + "description": "Daily email counters across the window. Every day in range is present, zero-filled." + }, + "AnalyticsCampaignStatsV1": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "active": { + "type": "integer", + "description": "Campaigns in DRAFT or SCHEDULED." + }, + "completed": { + "type": "integer" + }, + "average_open_rate": { + "type": "number", + "description": "Percentage, one decimal place." + }, + "average_click_rate": { + "type": "number" + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "total", + "active", + "completed", + "average_open_rate", + "average_click_rate", + "window" + ], + "description": "Campaign counters and engagement over the window." + }, + "AnalyticsTopCampaignsV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "subject": { + "type": "string" + }, + "sent": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + } + }, + "required": [ + "id", + "subject", + "sent", + "opened", + "clicked", + "open_rate", + "click_rate" + ] + } + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "data", + "window" + ], + "description": "Sent campaigns ranked by open rate." + }, + "DeliverabilityDiagnosisV1": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "address": { + "type": [ + "string", + "null" + ] + }, + "checked_at": { + "type": "string", + "format": "date-time" + }, + "identity": { + "$ref": "#/components/schemas/DeliverabilityIdentityV1" + }, + "suppression": { + "$ref": "#/components/schemas/DeliverabilitySuppressionV1" + }, + "recent_delivery": { + "$ref": "#/components/schemas/DeliverabilityRecentDeliveryV1" + }, + "findings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeliverabilityFindingV1" + }, + "description": "What is wrong, worst first. An empty array means nothing here explains a delivery problem." + } + }, + "required": [ + "domain", + "address", + "checked_at", + "identity", + "suppression", + "recent_delivery", + "findings" + ], + "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." + }, + "DeliverabilityIdentityV1": { + "type": "object", + "properties": { + "registered": { + "type": "boolean", + "description": "Whether this project has a domain record at all. False makes every other field null." + }, + "verified": { + "type": "boolean" + }, + "dkim_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "description": "DKIM signing. This is the one that decides whether Sendly will send from the domain at all." + }, + "spf_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "description": "SPF alignment for the sending identity." + }, + "dmarc_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "description": "The DMARC policy published at `_dmarc.`." + }, + "mx_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "description": "Inbound receiving only. Null unless the domain has receiving enabled." + }, + "mail_from_domain": { + "type": [ + "string", + "null" + ] + }, + "mail_from_domain_status": { + "type": [ + "string", + "null" + ], + "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." + }, + "last_checked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup." + } + }, + "required": [ + "registered", + "verified", + "dkim_status", + "spf_status", + "dmarc_status", + "mx_status", + "mail_from_domain", + "mail_from_domain_status", + "last_checked_at" + ], + "description": "The sending identity's DNS health, as last refreshed." + }, + "DeliverabilitySuppressionV1": { + "type": [ + "object", + "null" + ], + "properties": { + "suppressed": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ], + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE", + null + ] + }, + "source": { + "type": [ + "string", + "null" + ], + "enum": [ + "SES_WEBHOOK", + "API", + "DASHBOARD", + null + ] + }, + "suppressed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "required": [ + "suppressed", + "reason", + "source", + "suppressed_at" + ], + "description": "Null unless the request named an `address`." + }, + "DeliverabilityRecentDeliveryV1": { + "type": "object", + "properties": { + "window_days": { + "type": "integer" + }, + "scope": { + "type": "string", + "enum": [ + "project" + ], + "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." + }, + "sent": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "complained": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "bounce_rate": { + "type": [ + "number", + "null" + ], + "description": "Bounced ÷ sent (0–1), or null when nothing was sent in the window." + }, + "complaint_rate": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "window_days", + "scope", + "sent", + "delivered", + "bounced", + "complained", + "failed", + "bounce_rate", + "complaint_rate" + ], + "description": "Delivery outcomes over the requested window." + }, + "DeliverabilityFindingV1": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`." + }, + "severity": { + "$ref": "#/components/schemas/DeliverabilityFindingSeverityV1" + }, + "summary": { + "type": "string", + "description": "What is wrong, in one sentence." + }, + "remedy": { + "type": "string", + "description": "What to do about it." + } + }, + "required": [ + "code", + "severity", + "summary", + "remedy" + ], + "description": "One diagnosed problem, with its fix." + }, + "DeliverabilityFindingSeverityV1": { + "type": "string", + "enum": [ + "blocking", + "degraded", + "info" + ], + "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." + }, + "RecipientDomainStatsV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipientDomainStatsV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated recipient-domain rollup, newest day first." + }, + "RecipientDomainStatsV1": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "The recipient's domain, lowercased: the part after the `@`." + }, + "day": { + "type": "string", + "description": "The UTC day these counts cover, as `YYYY-MM-DD`." + }, + "sent": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "complained": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "computed_at": { + "type": "string", + "format": "date-time", + "description": "When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly." + } + }, + "required": [ + "domain", + "day", + "sent", + "delivered", + "bounced", + "complained", + "opened", + "computed_at" + ], + "description": "Delivery outcomes for one recipient domain on one day." + }, + "DmarcReportV1List": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DmarcReportV1" + } + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `after` to fetch the next page. `null` on the last page." + } + }, + "required": [ + "data", + "has_more", + "next_cursor" + ], + "description": "Cursor-paginated DMARC aggregate reports, newest window first." + }, + "DmarcReportV1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "report_id": { + "type": "string", + "description": "The receiver's own id for this report." + }, + "org_name": { + "type": "string", + "description": "The reporting receiver, e.g. `google.com`." + }, + "policy_domain": { + "type": "string", + "description": "The domain of yours the report is about." + }, + "range_begin": { + "type": "string", + "format": "date-time" + }, + "range_end": { + "type": "string", + "format": "date-time" + }, + "total_count": { + "type": "integer" + }, + "pass_count": { + "type": "integer", + "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." + }, + "fail_count": { + "type": "integer" + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_ip": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "disposition": { + "type": "string" + }, + "dkim": { + "type": "string" + }, + "spf": { + "type": "string" + }, + "header_from": { + "type": "string" + } + }, + "required": [ + "source_ip", + "count", + "disposition", + "dkim", + "spf", + "header_from" + ] + }, + "description": "Per-sending-source rows, as the receiver reported them." + }, + "received_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "report_id", + "org_name", + "policy_domain", + "range_begin", + "range_end", + "total_count", + "pass_count", + "fail_count", + "sources", + "received_at" + ], + "description": "One DMARC aggregate (RUA) report." + }, + "UsageV1": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "enum": [ + "free", + "pro", + "custom" + ], + "description": "`custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`." + }, + "monthly": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "description": "Monthly cap on the total. Null when per-category limits govern instead." + }, + "categories": { + "type": "object", + "properties": { + "transactional": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + }, + "campaign": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + }, + "workflow": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + }, + "inbound": { + "type": "object", + "properties": { + "emails_sent": { + "type": "integer" + }, + "limit": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "emails_sent", + "limit" + ] + } + }, + "required": [ + "transactional", + "campaign", + "workflow", + "inbound" + ] + } + }, + "required": [ + "emails_sent", + "limit", + "categories" + ] + }, + "daily": { + "type": "object", + "properties": { + "emails_sent": { + "type": [ + "integer", + "null" + ], + "description": "Today's sends. Null when the counter could not be read." + }, + "limit": { + "type": "integer" + }, + "trust_tier": { + "type": "string", + "enum": [ + "NEW", + "ESTABLISHED", + "TRUSTED" + ] + } + }, + "required": [ + "emails_sent", + "limit", + "trust_tier" + ] + } + }, + "required": [ + "plan", + "monthly", + "daily" + ], + "description": "Current email usage against the limits that are actually enforced." + }, + "ProjectV1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "disabled": { + "type": "boolean", + "description": "A disabled project sends nothing; every send is refused." + }, + "sandbox_address": { + "type": [ + "string", + "null" + ], + "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." + }, + "ses_region": { + "type": [ + "string", + "null" + ], + "description": "Locked once the first domain is added." + }, + "tracking": { + "type": "string", + "enum": [ + "ENABLED", + "DISABLED", + "MARKETING_ONLY" + ] + }, + "language": { + "type": "string", + "description": "ISO 639-1 code for customer-facing content." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "disabled", + "sandbox_address", + "ses_region", + "tracking", + "language", + "created_at" + ], + "description": "The project the presented credential is scoped to." + }, + "Mailbox": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "address": { + "type": "string", + "format": "email", + "description": "The full mailbox address, e.g. `support@superbooks.io`." + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string", + "enum": [ + "PROVISIONING", + "ACTIVE", + "SUSPENDED", + "FAILED" + ], + "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." + }, + "quotaBytes": { + "type": [ + "number", + "null" + ], + "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." + }, + "domainId": { + "type": "string", + "format": "uuid", + "description": "The verified domain this mailbox lives on." + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "address", + "displayName", + "status", + "quotaBytes", + "domainId", + "createdAt" + ], + "description": "A receiving mailbox on one of the project's verified domains." + }, + "MailboxDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Mailbox" + }, + { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": { + "imap": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "security": { + "type": "string", + "description": "Transport security, e.g. `SSL/TLS`." + }, + "username": { + "type": "string", + "description": "The mailbox address — it is also the login." + } + }, + "required": [ + "host", + "port", + "security", + "username" + ] + }, + "smtp": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "security": { + "type": "string", + "description": "Transport security, e.g. `SSL/TLS`." + }, + "username": { + "type": "string", + "description": "The mailbox address — it is also the login." + } + }, + "required": [ + "host", + "port", + "security", + "username" + ] + } + }, + "required": [ + "imap", + "smtp" + ], + "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." + } + }, + "required": [ + "settings" + ] + } + ], + "description": "A mailbox plus its IMAP/SMTP connection settings." + }, + "CreateMailboxBody": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "format": "uuid", + "description": "Defaults to the project the credential resolves to. Naming a different one is refused." + }, + "domainId": { + "type": "string", + "format": "uuid", + "description": "A VERIFIED domain belonging to this project." + }, + "localPart": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "The part before the `@`, e.g. `support`. Lowercased server-side." + }, + "displayName": { + "type": "string", + "maxLength": 120 + }, + "quotaBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "NOT IMPLEMENTED — sending any value answers 400." + } + }, + "required": [ + "domainId", + "localPart" + ] + }, + "DraftMailboxMessage": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "draft", + "rewrite", + "subject" + ] + }, + "brief": { + "type": "string", + "maxLength": 4000 + }, + "draft": { + "type": "string", + "maxLength": 20000 + }, + "instruction": { + "type": "string", + "maxLength": 500 + }, + "tone": { + "type": "string", + "enum": [ + "friendly", + "neutral", + "formal", + "apologetic", + "direct" + ] + }, + "recipientContext": { + "type": "string", + "maxLength": 2000 + }, + "senderAddress": { + "type": "string", + "maxLength": 320 + } + }, + "required": [ + "mode" + ], + "description": "Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending." + }, + "ComposeMailboxMessage": { + "type": "object", + "properties": { + "to": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "minItems": 1, + "maxItems": 20 + }, + "cc": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 20 + }, + "bcc": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 20 + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 50000 + } + }, + "required": [ + "to", + "subject", + "body" + ], + "description": "Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox." + }, + "AppPassword": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "What the credential is for, e.g. `Thunderbird on my laptop`." + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "imap", + "smtp" + ] + }, + "description": "Which protocols this password may authenticate. `imap` reads, `smtp` sends." + }, + "lastFour": { + "type": "string", + "description": "The last four characters of the secret — enough to tell two credentials apart, and nothing more." + }, + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Null until a mail client has authenticated with it at least once." + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "scopes", + "lastFour", + "lastUsedAt", + "createdAt" + ], + "description": "An IMAP/SMTP credential for one mailbox, described but never reproduced." + }, + "AppPasswordReveal": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "revealUrl": { + "type": "string", + "format": "uri", + "description": "A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it." + }, + "revealExpiresAt": { + "type": "string", + "format": "date-time", + "description": "When the link stops working. Five minutes after creation; the password itself does not expire." + } + }, + "required": [ + "id", + "revealUrl", + "revealExpiresAt" + ], + "description": "A newly created app password, handed over as a one-time link rather than as a secret." + }, + "CreateAppPassword": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 60 + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "imap", + "smtp" + ] + }, + "minItems": 1, + "default": [ + "imap", + "smtp" + ] + } + }, + "required": [ + "name" + ], + "description": "Body for POST /api/mailboxes/:id/app-passwords." + }, + "VerifyEmail": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ], + "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc." + }, + "TopicListV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TopicV1" + } + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "has_more": { + "type": "boolean" + } + }, + "required": [ + "data", + "cursor", + "has_more" + ] + }, + "TopicV1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "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." + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "default_opt_in": { + "type": "boolean", + "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`." + }, + "archived": { + "type": "boolean", + "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." + }, + "subscribed_count": { + "type": "integer", + "description": "Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`." + }, + "unsubscribed_count": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "key", + "name", + "description", + "default_opt_in", + "archived", + "subscribed_count", + "unsubscribed_count", + "created_at" + ], + "description": "One subject this project mails about." + }, + "TopicCreateV1": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "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." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1000 + }, + "default_opt_in": { + "type": "boolean" + } + }, + "required": [ + "key", + "name" + ] + }, + "TopicUpdateV1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1000 + }, + "default_opt_in": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + } + }, + "description": "`key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them." + }, + "TopicSubscriptionV1": { + "type": "object", + "properties": { + "topic_id": { + "type": "string" + }, + "contact_id": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/TopicSubscriptionStatusV1" + }, + "confirmed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "confirmation_url": { + "type": [ + "string", + "null" + ], + "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." + } + }, + "required": [ + "topic_id", + "contact_id", + "status", + "confirmed_at", + "confirmation_url" + ] + }, + "TopicSubscriptionStatusV1": { + "type": "string", + "enum": [ + "pending", + "subscribed", + "unsubscribed" + ], + "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." + }, + "TopicSubscribeV1": { + "type": "object", + "properties": { + "contact_id": { + "type": "string", + "format": "uuid" + }, + "subscribed": { + "type": "boolean", + "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." + } + }, + "required": [ + "contact_id", + "subscribed" + ] + }, + "ContactTopicPreferencesV1": { + "type": "object", + "properties": { + "contact_id": { + "type": "string" + }, + "subscribed": { + "type": "boolean", + "description": "The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say." + }, + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "topic_id": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "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." + }, + "name": { + "type": "string" + }, + "subscribed": { + "type": "boolean", + "description": "The EFFECTIVE answer: what the send path concludes for this contact today." + }, + "pending": { + "type": "boolean" + } + }, + "required": [ + "topic_id", + "key", + "name", + "subscribed", + "pending" + ] + } + } + }, + "required": [ + "contact_id", + "subscribed", + "topics" + ], + "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." + }, + "EmailValidationBatchV1": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailValidationV1" + } + } + }, + "required": [ + "results" + ], + "description": "One verdict per address, in the order they were given." + }, + "EmailValidationV1": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "verdict": { + "$ref": "#/components/schemas/EmailValidationVerdictV1" + }, + "is_disposable": { + "type": "boolean", + "description": "A throwaway-inbox provider. The ONLY flag here that lowers the verdict." + }, + "is_role_address": { + "type": "boolean", + "description": "The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them." + }, + "is_personal": { + "type": "boolean", + "description": "A free/consumer provider (Gmail, Outlook). List-quality information, not a problem." + }, + "has_mx_records": { + "type": "boolean", + "description": "The domain publishes MX records." + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Human-readable findings. Prose for a person to read — branch on `verdict`, never on these." + } + }, + "required": [ + "email", + "verdict", + "is_disposable", + "is_role_address", + "is_personal", + "has_mx_records", + "reasons" + ], + "description": "One address's verdict, with the evidence behind it." + }, + "EmailValidationVerdictV1": { + "type": "string", + "enum": [ + "deliverable", + "undeliverable", + "risky", + "unknown" + ], + "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`." + }, + "EmailValidationBatchRequestV1": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "minItems": 1, + "maxItems": 50, + "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." + } + }, + "required": [ + "emails" + ] + }, + "EmailValidationRunV1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "list_id": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "completed", + "failed" + ] + }, + "processed_count": { + "type": "integer", + "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." + }, + "deliverable_count": { + "type": "integer" + }, + "undeliverable_count": { + "type": "integer" + }, + "risky_count": { + "type": "integer" + }, + "started_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "failure_reason": { + "type": [ + "string", + "null" + ], + "description": "Set only on `failed`. Prose for an operator; never parse it." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "list_id", + "status", + "processed_count", + "deliverable_count", + "undeliverable_count", + "risky_count", + "started_at", + "completed_at", + "failure_reason", + "created_at" + ], + "description": "One bulk validation run over a list." + }, + "EmailValidationResultListV1": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/EmailValidationV1" + }, + { + "type": "object", + "properties": { + "contact_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "contact_id" + ] + } + ], + "description": "One address's verdict, with the evidence behind it." + } + }, + "cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `cursor` for the next page; null on the last." + }, + "has_more": { + "type": "boolean" + } + }, + "required": [ + "data", + "cursor", + "has_more" + ], + "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." + } + }, + "parameters": {} + }, + "paths": { + "/api/v1/campaigns": { + "get": { + "operationId": "v1ListCampaigns", + "tags": [ + "Campaigns" + ], + "summary": "List campaigns", + "description": "Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Campaign list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1CreateCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Create a campaign", + "description": "Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything.\n\nThe `from` address is checked against this project's verified domains before the campaign is written, so a campaign never exists with a sender it cannot use.\n\n`segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." + }, + "required": false, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Create" + } + } + } + }, + "responses": { + "201": { + "description": "Campaign created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — `segment_id` names a segment that does not belong to this project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — the request did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. A key names ONE request, so it is never silently served another one's result: send a new key.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}": { + "get": { + "operationId": "v1GetCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Retrieve a campaign", + "description": "Fetch one campaign, including its materialized delivery counters.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "patch": { + "operationId": "v1UpdateCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Update a campaign", + "description": "Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400.\n\nChanging `from` re-verifies the sender domain. Changing the audience on a `DRAFT` campaign schedules a recipient recount, so `stats.total_recipients` converges shortly after the response.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Update" + } + } + } + }, + "responses": { + "200": { + "description": "The updated campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — the campaign is not in an editable status, or the segment change is not allowed.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "operationId": "v1DeleteCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Delete a campaign", + "description": "Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Campaign deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Deleted" + } + } + } + }, + "400": { + "description": "`validation_error` — only `DRAFT` campaigns can be deleted.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/send": { + "post": { + "operationId": "v1SendCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Send or schedule a campaign", + "description": "Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now.\n\n**Send an `Idempotency-Key`.** This is the operation that cannot be undone: a retry without one starts a second fan-out over the same audience. The key is scoped to this campaign, so the same key on a different campaign is a `422 idempotency_key_reused` rather than a replay of the first campaign's response.\n\nAnswers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit.\n\nRequires the `campaigns:send` scope — Send or schedule your campaigns to their audience.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:send" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." + }, + "required": false, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Send" + } + } + } + }, + "responses": { + "200": { + "description": "The campaign, now `SENDING` or `SCHEDULED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — the request did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. A key names ONE request, so it is never silently served another one's result: send a new key.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/cancel": { + "post": { + "operationId": "v1CancelCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Cancel a campaign", + "description": "Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body.\n\nLike every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The cancelled campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/pause": { + "post": { + "operationId": "v1PauseCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Pause a sending campaign", + "description": "Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The paused campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — only a `SENDING` campaign can be paused.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/resume": { + "post": { + "operationId": "v1ResumeCampaign", + "tags": [ + "Campaigns" + ], + "summary": "Resume a paused campaign", + "description": "Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The resumed campaign", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" + } + } + } + }, + "400": { + "description": "`validation_error` — only a `PAUSED` campaign can be resumed.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/stats": { + "get": { + "operationId": "v1GetCampaignStats", + "tags": [ + "Campaigns" + ], + "summary": "Retrieve campaign statistics", + "description": "Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight.\n\nRates are percentages (0–100) against `sent`, and are 0 before anything has been sent.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Campaign statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Stats" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/failures": { + "get": { + "operationId": "v1ListCampaignFailures", + "tags": [ + "Campaigns" + ], + "summary": "List a campaign's failed sends", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Failed sends", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1FailureList" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{id}/retry-failed": { + "post": { + "operationId": "v1RetryCampaignFailures", + "tags": [ + "Campaigns" + ], + "summary": "Retry a campaign's failed sends", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "campaigns:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The retry was queued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1RetryFailed" + } + } + } + }, + "400": { + "description": "`validation_error` — only a `SENT` campaign can have its failed sends retried.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — a retry is already running for this campaign.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/segments": { + "get": { + "operationId": "v1ListSegments", + "tags": [ + "Segments" + ], + "summary": "List segments", + "description": "Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\n`member_count` is materialized on each segment, so listing segments never fans out into one count per segment — it is refreshed as membership changes rather than computed on read.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "segments:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Segment list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1CreateSegment", + "tags": [ + "Segments" + ], + "summary": "Create a segment", + "description": "Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later.\n\nA `DYNAMIC` segment requires a `condition`, which is validated and evaluated during the request: the response's `member_count` tells you immediately how many contacts the filter actually matches.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "segments:write" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1Create" + } + } + } + }, + "responses": { + "201": { + "description": "Segment created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1" + } + } + } + }, + "400": { + "description": "`validation_error` — a `DYNAMIC` segment was submitted without a `condition`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/segments/{id}": { + "get": { + "operationId": "v1GetSegment", + "tags": [ + "Segments" + ], + "summary": "Retrieve a segment", + "description": "Fetch one segment, including its saved `condition` and materialized `member_count`.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "segments:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The segment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "patch": { + "operationId": "v1UpdateSegment", + "tags": [ + "Segments" + ], + "summary": "Update a segment", + "description": "Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list.\n\n`type` is not accepted here — see the create operation.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "segments:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1Update" + } + } + } + }, + "responses": { + "200": { + "description": "The updated segment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "operationId": "v1DeleteSegment", + "tags": [ + "Segments" + ], + "summary": "Delete a segment", + "description": "Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "segments:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Segment deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1Deleted" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the segment is still used by one or more active campaigns.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/segments/{id}/contacts": { + "get": { + "operationId": "v1ListSegmentContacts", + "tags": [ + "Segments" + ], + "summary": "List the contacts in a segment", + "description": "Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now.\n\nCursors from this endpoint are not interchangeable with cursors from other list endpoints — the ordering differs — and pairing one with the wrong endpoint answers 422 rather than silently paging a different set.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "segments:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Segment member list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentContactV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows": { + "get": { + "operationId": "v1ListWorkflows", + "tags": [ + "Workflows" + ], + "summary": "List workflows", + "description": "Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Workflow list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1CreateWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Create a workflow", + "description": "Creates 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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowCreateV1" + } + } + } + }, + "responses": { + "201": { + "description": "Workflow created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}": { + "get": { + "operationId": "v1GetWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Retrieve a workflow", + "description": "The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Workflow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "patch": { + "operationId": "v1UpdateWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Update a workflow", + "description": "Sparse update — omitted fields are left unchanged.\n\nTwo state rules apply: the trigger (`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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowUpdateV1" + } + } + } + }, + "responses": { + "200": { + "description": "Updated workflow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the trigger cannot be changed while executions are running.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "operationId": "v1DeleteWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Delete a workflow", + "description": "Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Workflow deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowDeletedV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the workflow still has running executions.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/executions": { + "get": { + "operationId": "v1ListWorkflowExecutions", + "tags": [ + "Workflows" + ], + "summary": "List a workflow's executions", + "description": "One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "RUNNING", + "WAITING", + "COMPLETED", + "EXITED", + "FAILED", + "CANCELLED" + ], + "description": "Return only executions in this state." + }, + "required": false, + "description": "Return only executions in this state.", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Execution list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1StartWorkflowExecution", + "tags": [ + "Workflows" + ], + "summary": "Start a workflow for a contact", + "description": "Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished.\n\n409 when the workflow's re-entry policy already accounts for this contact; 429 when the workflow's own `max_executions_per_hour` cap is reached.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStartV1" + } + } + } + }, + "responses": { + "201": { + "description": "Execution started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no such workflow, or no such contact in this project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the contact already has an execution and re-entry is not allowed.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/executions/{execution_id}/cancel": { + "post": { + "operationId": "v1CancelWorkflowExecution", + "tags": [ + "Workflows" + ], + "summary": "Cancel a workflow execution", + "description": "Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow execution id." + }, + "required": true, + "description": "Workflow execution id.", + "name": "execution_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Cancelled execution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no execution with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/stats": { + "get": { + "operationId": "v1GetWorkflowStats", + "tags": [ + "Workflows" + ], + "summary": "Retrieve workflow statistics", + "description": "Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow.\n\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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time." + }, + "required": false, + "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time.", + "name": "from", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Workflow statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowStatsV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/graph": { + "get": { + "operationId": "v1GetWorkflowGraph", + "tags": [ + "Workflows" + ], + "summary": "Retrieve a workflow's step graph", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The workflow's graph", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGraphV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "put": { + "operationId": "v1ReplaceWorkflowGraph", + "tags": [ + "Workflows" + ], + "summary": "Replace a workflow's step graph", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGraphReplaceV1" + } + } + } + }, + "responses": { + "200": { + "description": "The graph as it now stands", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGraphV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/clone": { + "post": { + "operationId": "v1CloneWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Clone a workflow", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowCloneV1" + } + } + } + }, + "responses": { + "201": { + "description": "The cloned workflow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/pause": { + "post": { + "operationId": "v1PauseWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Pause a workflow and cancel its running executions", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The workflow, and the number of runs this call cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowStateChangeV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/workflows/{id}/resume": { + "post": { + "operationId": "v1ResumeWorkflow", + "tags": [ + "Workflows" + ], + "summary": "Resume a paused workflow", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "workflows:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Workflow id." + }, + "required": true, + "description": "Workflow id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The workflow, with `cancelled_executions` always 0", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowStateChangeV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/emails": { + "post": { + "operationId": "v1SendEmail", + "tags": [ + "Emails" + ], + "summary": "Send a transactional email", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "emails:send" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." + }, + "required": false, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendEmailV1" + } + } + } + }, + "responses": { + "202": { + "description": "Email queued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_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.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — `template` names a template that does not belong to this project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — the 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.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "503": { + "description": "`content_review_unavailable` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/emails/test": { + "post": { + "operationId": "v1SendTestEmail", + "tags": [ + "Emails" + ], + "summary": "Send a sandbox test email", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "emails:test" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendTestEmailV1" + } + } + } + }, + "responses": { + "202": { + "description": "Test email queued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailTestV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_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.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "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.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "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.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "503": { + "description": "`content_review_unavailable` — content review could not run for this new account. Safe to retry.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/emails": { + "post": { + "operationId": "sendEmail", + "tags": [ + "Emails" + ], + "summary": "Send a single transactional email", + "description": "Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result.\n\nRequires the `emails:send` scope — Send emails from your verified domains.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "emails:send" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." + }, + "required": false, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendEmail" + } + } + } + }, + "responses": { + "200": { + "description": "Email accepted / sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendEmailResponse" + } + } + } + }, + "400": { + "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`IDEMPOTENCY_KEY_REUSED` — this `Idempotency-Key` was already used for a request with a different body. Reuse a key only to retry the identical request; otherwise send a new key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "`CONTENT_REVIEW_UNAVAILABLE` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "get": { + "operationId": "listEmails", + "tags": [ + "Emails" + ], + "summary": "List emails", + "description": "List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets.\n\nRequires the `emails:read` scope — View the emails you have sent and their delivery status.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "emails:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "tag", + "in": "query" + }, + { + "schema": { + "$ref": "#/components/schemas/EmailDeliveryStatus" + }, + "required": false, + "description": "Delivery lifecycle of the message. Engagement is reported separately.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "from", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Email list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailListResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/emails/{id}": { + "get": { + "operationId": "getEmail", + "tags": [ + "Emails" + ], + "summary": "Get a single email", + "description": "Fetch one email 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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "emails:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Email and its delivery history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailDetailResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/emails/batch": { + "post": { + "operationId": "sendEmailBatch", + "tags": [ + "Emails" + ], + "summary": "Send a batch of emails", + "description": "Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array.\n\nThe whole batch is ONE idempotent unit: an `Idempotency-Key` replayed with the same entry list replays the same per-index results, and replaying it with an edited list answers `422 IDEMPOTENCY_KEY_REUSED` rather than returning results for indexes the new body no longer has.\n\nRequires the `emails:send` scope — Send emails from your verified domains.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "emails:send" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "required": false, + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchSendBody" + } + } + } + }, + "responses": { + "200": { + "description": "All entries sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchSendResponse" + } + } + } + }, + "207": { + "description": "Partial success — at least one entry failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchSendResponse" + } + } + } + }, + "400": { + "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`IDEMPOTENCY_KEY_REUSED` — this `Idempotency-Key` was already used for a request with a different body. Reuse a key only to retry the identical request; otherwise send a new key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "`CONTENT_REVIEW_UNAVAILABLE` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/emails/{id}/schedule": { + "delete": { + "operationId": "cancelScheduledEmail", + "tags": [ + "Emails" + ], + "summary": "Cancel a scheduled (still-PENDING) email", + "description": "Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Email cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Email already past PENDING", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/contacts": { + "get": { + "operationId": "listContacts", + "tags": [ + "Contacts" + ], + "summary": "List contacts", + "description": "Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "search", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + "required": false, + "name": "subscribed", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Contact list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactListResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createContact", + "tags": [ + "Contacts" + ], + "summary": "Create a contact", + "description": "Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateContact" + } + } + } + }, + "responses": { + "201": { + "description": "Contact created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Email already exists for this project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/contacts/upsert": { + "post": { + "operationId": "upsertContact", + "tags": [ + "Contacts" + ], + "summary": "Create or update a contact by email", + "description": "Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateContact" + } + } + } + }, + "responses": { + "200": { + "description": "Contact created or updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/contacts/bulk": { + "post": { + "operationId": "bulkCreateContacts", + "tags": [ + "Contacts" + ], + "summary": "Bulk-create contacts", + "description": "Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactBulkCreateBody" + } + } + } + }, + "responses": { + "200": { + "description": "Bulk-create result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "created": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "index", + "message" + ] + } + } + }, + "required": [ + "created", + "skipped", + "errors" + ] + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "bulkDeleteContacts", + "tags": [ + "Contacts" + ], + "summary": "Bulk-delete contacts", + "description": "Delete up to 1000 contacts in one call. Provide either `ids` or `emails`.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactBulkDeleteBody" + } + } + } + }, + "responses": { + "200": { + "description": "Bulk-delete result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "deleted": { + "type": "integer" + } + }, + "required": [ + "deleted" + ] + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/contacts/{id}": { + "get": { + "operationId": "getContact", + "tags": [ + "Contacts" + ], + "summary": "Get a contact", + "description": "Requires the `contacts:read` scope — View your contacts and their custom fields.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Contact", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "updateContact", + "tags": [ + "Contacts" + ], + "summary": "Update a contact", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateContactBody" + } + } + } + }, + "responses": { + "200": { + "description": "Updated contact", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Contact" + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteContact", + "tags": [ + "Contacts" + ], + "summary": "Delete a contact", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Contact deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/contacts": { + "get": { + "operationId": "v1ListContacts", + "tags": [ + "Contacts" + ], + "summary": "List contacts", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Case-insensitive substring match on the email address." + }, + "required": false, + "description": "Case-insensitive substring match on the email address.", + "name": "search", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ], + "description": "Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both." + }, + "required": false, + "description": "Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both.", + "name": "subscribed", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Contact list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1List" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "post": { + "operationId": "v1CreateContact", + "tags": [ + "Contacts" + ], + "summary": "Create a contact", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1Create" + } + } + } + }, + "responses": { + "201": { + "description": "The created contact", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "409": { + "description": "`conflict` — a contact with this email already exists in this project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/contacts/{id}": { + "get": { + "operationId": "v1GetContact", + "tags": [ + "Contacts" + ], + "summary": "Retrieve a contact", + "description": "Fetch one contact by id.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The contact", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no contact with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "patch": { + "operationId": "v1UpdateContact", + "tags": [ + "Contacts" + ], + "summary": "Update a contact", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1Update" + } + } + } + }, + "responses": { + "200": { + "description": "The updated contact", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no contact with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "operationId": "v1DeleteContact", + "tags": [ + "Contacts" + ], + "summary": "Delete a contact", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:write" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Contact deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1Deleted" + } + } + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no contact with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/lists/{id}/subscribe": { + "post": { + "operationId": "subscribeToList", + "tags": [ + "Lists" + ], + "summary": "Subscribe a contact to a list", + "description": "Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "List id." + }, + "required": true, + "description": "List id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSubscribe" + } + } + } + }, + "responses": { + "200": { + "description": "Contact subscribed, or an existing membership returned unchanged", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSubscribeResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "The contact previously unsubscribed from this list and `allowResubscribe` was not set. `error.code` is `RESUBSCRIBE_CONFIRMATION_REQUIRED` and `error.details.previousStatus` is `UNSUBSCRIBED`. Retry with `allowResubscribe: true` once the contact has consented.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/lists/{id}/unsubscribe": { + "post": { + "operationId": "unsubscribeFromList", + "tags": [ + "Lists" + ], + "summary": "Unsubscribe a contact from a list", + "description": "Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds.\n\nOnce a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "List id." }, - "tokenUrl": "https://app.sendly.now/api/auth/oauth2/token" + "required": true, + "description": "List id.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnsubscribe" + } + } + } + }, + "responses": { + "200": { + "description": "Contact unsubscribed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnsubscribeResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } - }, - "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": { + "/api/v1/lists": { "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", + "operationId": "v1ListLists", + "tags": [ + "Lists" + ], + "summary": "List subscriber lists", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "lists:read" + ] + } + ], "parameters": [ { - "in": "query", - "name": "limit", - "required": false, "schema": { - "default": 50, - "maximum": 100, + "type": "integer", "minimum": 1, - "type": "integer" - } + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" }, { - "in": "query", - "name": "cursor", - "required": false, "schema": { - "type": "string" + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Subscriber lists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListV1List" + } + } } }, - { - "in": "query", - "name": "search", - "required": false, - "schema": { - "type": "string" + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } } }, - { - "in": "query", - "name": "subscribed", - "required": false, - "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } } } + } + }, + "post": { + "operationId": "v1CreateList", + "tags": [ + "Lists" + ], + "summary": "Create a subscriber list", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "lists:write" + ] + } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListV1Create" + } + } + } + }, "responses": { - "200": { + "201": { + "description": "The created list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactListResponse" + "$ref": "#/components/schemas/ListV1" } } - }, - "description": "Contact list" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/v1/lists/{id}": { + "get": { + "operationId": "v1GetList", + "tags": [ + "Lists" + ], + "summary": "Retrieve a subscriber list", + "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.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "lists:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ListV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "404": { + "description": "`resource_not_found` — no list with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/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" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "patch": { + "operationId": "v1UpdateList", + "tags": [ + "Lists" + ], + "summary": "Update a subscriber list", + "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.", "security": [ { "ApiKeyAuth": [] @@ -4548,125 +15008,113 @@ }, { "OAuth2": [ - "contacts:read" + "lists:write" ] } ], - "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", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateContact" + "$ref": "#/components/schemas/ListV1Update" } } - }, - "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": { + "200": { + "description": "The updated list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ListV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "409": { + "404": { + "description": "`resource_not_found` — no list with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Email already exists for this project" + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "delete": { + "operationId": "v1DeleteList", + "tags": [ + "Lists" + ], + "summary": "Delete a subscriber list", + "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.", "security": [ { "ApiKeyAuth": [] @@ -4676,125 +15124,188 @@ }, { "OAuth2": [ - "contacts:write" + "lists: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 - }, + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], "responses": { "200": { + "description": "List deleted", "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/ListV1Deleted" } } - }, - "description": "Bulk-delete result" + } + }, + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "`resource_not_found` — no list with this id belongs to the authenticated project.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "`internal_error`.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/api/domains": { + "get": { + "operationId": "listDomains", + "tags": [ + "Domains" + ], + "summary": "List sending domains", + "description": "List all domains for the authenticated project.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.", + "security": [ + { + "ApiKeyAuth": [] }, - "400": { + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "domains:read" + ] + } + ], + "responses": { + "200": { + "description": "Domain list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/DomainListResponse" } } - }, - "description": "Validation error" + } }, - "401": { + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "422": { + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "post": { + "operationId": "addDomain", + "tags": [ + "Domains" + ], + "summary": "Add a sending domain", + "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.", "security": [ { "ApiKeyAuth": [] @@ -4804,146 +15315,117 @@ }, { "OAuth2": [ - "contacts:write" + "domains: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": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactBulkCreateBody" + "$ref": "#/components/schemas/AddDomainBody" } } - }, - "required": true + } }, "responses": { - "200": { + "201": { + "description": "Domain added", "content": { "application/json": { "schema": { + "type": "object", "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": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "$ref": "#/components/schemas/Domain" } }, "required": [ "success", "data" - ], - "type": "object" + ] } } - }, - "description": "Bulk-create result" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "422": { + "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, - "429": { + "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, - "500": { + "502": { + "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.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/domains/{id}": { + "get": { + "operationId": "getDomain", + "tags": [ + "Domains" + ], + "summary": "Get a sending domain", + "description": "Requires the `domains:read` scope — View your sending domains and their verification status.", "security": [ { "ApiKeyAuth": [] @@ -4953,117 +15435,116 @@ }, { "OAuth2": [ - "contacts:write" + "domains:read" ] } ], - "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 - }, + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { + "description": "Domain", "content": { "application/json": { "schema": { + "type": "object", "properties": { - "data": { - "$ref": "#/components/schemas/Contact" - }, "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "$ref": "#/components/schemas/Domain" } }, "required": [ "success", "data" - ], - "type": "object" + ] } } - }, - "description": "Contact created or updated" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "422": { + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "patch": { + "operationId": "assignDomainStream", + "tags": [ + "Domains" + ], + "summary": "Assign a sending identity to a stream", + "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.", "security": [ { "ApiKeyAuth": [] @@ -5073,103 +15554,126 @@ }, { "OAuth2": [ - "contacts:write" + "domains: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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignDomainStream" + } + } + } + }, "responses": { "200": { + "description": "Updated sending identity", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Domain" + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Contact deleted" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "delete": { + "operationId": "deleteDomain", + "tags": [ + "Domains" + ], + "summary": "Remove a sending domain", + "description": "Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.", "security": [ { "ApiKeyAuth": [] @@ -5179,116 +15683,103 @@ }, { "OAuth2": [ - "contacts:write" + "domains: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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "Domain removed", "content": { "application/json": { "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Contact" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" + "$ref": "#/components/schemas/SuccessEmpty" } } - }, - "description": "Contact" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/domains/{id}/verify": { + "post": { + "operationId": "verifyDomain", + "tags": [ + "Domains" + ], + "summary": "Trigger SES verification", + "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.", "security": [ { "ApiKeyAuth": [] @@ -5298,136 +15789,116 @@ }, { "OAuth2": [ - "contacts:read" + "domains:write" ] } ], - "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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateContactBody" - } - } - }, - "required": true - }, "responses": { "200": { + "description": "Verification status", "content": { "application/json": { "schema": { + "type": "object", "properties": { - "data": { - "$ref": "#/components/schemas/Contact" - }, "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "$ref": "#/components/schemas/DomainVerificationStatus" } }, "required": [ "success", "data" - ], - "type": "object" + ] } } - }, - "description": "Updated contact" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "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": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "get": { + "operationId": "getDomainVerification", + "tags": [ + "Domains" + ], + "summary": "Read SES verification status", + "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.", "security": [ { "ApiKeyAuth": [] @@ -5437,82 +15908,118 @@ }, { "OAuth2": [ - "contacts:write" + "domains: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", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { + "description": "Verification status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainListResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/DomainVerificationStatus" + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Domain list" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/domains/{id}/dodomain-session": { + "post": { + "operationId": "startDomainSetup", + "tags": [ + "Domains" + ], + "summary": "Start guided DNS setup", + "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.", "security": [ { "ApiKeyAuth": [] @@ -5522,115 +16029,137 @@ }, { "OAuth2": [ - "domains:read" + "domains:write" ] } ], - "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 - }, + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { - "201": { + "200": { + "description": "Guided setup session", "content": { "application/json": { "schema": { + "type": "object", "properties": { - "data": { - "$ref": "#/components/schemas/Domain" - }, "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "connectUrl": { + "type": "string", + "format": "uri", + "description": "Open this in a browser to publish the records. Short-lived and domain-specific." + }, + "expiresAt": { + "type": "string", + "description": "When `connectUrl` stops working." + } + }, + "required": [ + "token", + "connectUrl", + "expiresAt" + ] } }, "required": [ "success", "data" - ], - "type": "object" + ] } } - }, - "description": "Domain added" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "429": { + "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, - "500": { + "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } }, - "502": { + "500": { + "description": "Internal server error", "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." + } } - }, + } + } + }, + "/api/v1/domains": { + "get": { + "operationId": "v1ListDomains", + "tags": [ + "Domains" + ], + "summary": "List sending domains", + "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.", "security": [ { "ApiKeyAuth": [] @@ -5640,103 +16169,104 @@ }, { "OAuth2": [ - "domains:write" + "domains:read" ] } ], - "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" - } + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" } ], "responses": { "200": { + "description": "Sending domain list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessEmpty" - } - } - }, - "description": "Domain removed" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/DomainV1List" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "post": { + "operationId": "v1CreateDomain", + "tags": [ + "Domains" + ], + "summary": "Add a sending domain", + "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.", "security": [ { "ApiKeyAuth": [] @@ -5750,112 +16280,108 @@ ] } ], - "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" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainV1Create" + } } } - ], + }, "responses": { - "200": { + "201": { + "description": "The registered sending domain, awaiting DNS", "content": { "application/json": { "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Domain" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" + "$ref": "#/components/schemas/DomainV1" } } - }, - "description": "Domain" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "409": { + "description": "`conflict` — this domain is already registered to a project you can send from.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } + }, + "502": { + "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.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } - }, + } + } + }, + "/api/v1/domains/{id}": { + "get": { + "operationId": "v1GetDomain", + "tags": [ + "Domains" + ], + "summary": "Retrieve a sending domain", + "description": "Fetch one sending domain by id.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.", "security": [ { "ApiKeyAuth": [] @@ -5869,133 +16395,99 @@ ] } ], - "summary": "Get a sending domain", - "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": { + "type": "string", "format": "uuid", - "type": "string" - } + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "The sending domain", "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" + "$ref": "#/components/schemas/DomainV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "delete": { + "operationId": "v1DeleteDomain", + "tags": [ + "Domains" + ], + "summary": "Remove a sending domain", + "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.", "security": [ { "ApiKeyAuth": [] @@ -6009,114 +16501,111 @@ ] } ], - "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": { + "type": "string", "format": "uuid", - "type": "string" - } + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "Sending domain removed", "content": { "application/json": { "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/DomainVerificationStatus" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" + "$ref": "#/components/schemas/DomainV1Deleted" } } - }, - "description": "Verification status" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "404": { + "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "409": { + "description": "`conflict` — the domain is still in use by a template, workflow step or active campaign.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/domains/{id}/verify": { + "post": { + "operationId": "v1VerifyDomain", + "tags": [ + "Domains" + ], + "summary": "Refresh a sending domain's verification state", + "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.", "security": [ { "ApiKeyAuth": [] @@ -6130,112 +16619,101 @@ ] } ], - "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": { + "type": "string", "format": "uuid", - "type": "string" - } + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "The sending domain, as SES and DNS now report it", "content": { "application/json": { "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/DomainVerificationStatus" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" + "$ref": "#/components/schemas/DomainV1" } } - }, - "description": "Verification status" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "404": { + "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/templates": { + "get": { + "operationId": "listTemplates", + "tags": [ + "Templates" + ], + "summary": "List templates", + "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.", "security": [ { "ApiKeyAuth": [] @@ -6245,137 +16723,133 @@ }, { "OAuth2": [ - "domains:write" + "templates:read" ] } ], - "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, + "type": "integer", "minimum": 1, - "type": "integer" - } - }, - { - "in": "query", - "name": "cursor", + "maximum": 100, + "default": 20 + }, "required": false, - "schema": { - "type": "string" - } + "name": "limit", + "in": "query" }, { - "in": "query", - "name": "tag", - "required": false, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": false, + "name": "cursor", + "in": "query" }, { - "in": "query", - "name": "status", - "required": false, "schema": { - "enum": [ - "PENDING", - "SENT", - "DELIVERED", - "OPENED", - "CLICKED", - "BOUNCED", - "COMPLAINED", - "FAILED" - ], "type": "string" - } + }, + "required": false, + "name": "search", + "in": "query" }, { - "in": "query", - "name": "from", - "required": false, "schema": { - "type": "string" - } + "type": "string", + "enum": [ + "MARKETING", + "TRANSACTIONAL", + "SELF_MANAGED_UNSUBSCRIBE" + ] + }, + "required": false, + "name": "emailCategory", + "in": "query" } ], "responses": { "200": { + "description": "Template list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailListResponse" + "$ref": "#/components/schemas/TemplateListResponse" } } - }, - "description": "Email list" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "post": { + "operationId": "createTemplate", + "tags": [ + "Templates" + ], + "summary": "Create a template", + "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.", "security": [ { "ApiKeyAuth": [] @@ -6385,134 +16859,117 @@ }, { "OAuth2": [ - "emails:read" + "templates:write" ] } ], - "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": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SendEmail" + "$ref": "#/components/schemas/CreateTemplate" } } - }, - "required": true + } }, "responses": { - "200": { + "201": { + "description": "Template created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SendEmailResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Template" + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Email accepted / sent" + } }, "400": { + "description": "Validation error", "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": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "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": { + "description": "Validation failed — request body or query parameters did not match the schema", "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": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "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." + } } - }, + } + } + }, + "/api/templates/{id}": { + "get": { + "operationId": "getTemplate", + "tags": [ + "Templates" + ], + "summary": "Get a template", + "description": "Requires the `templates:read` scope — View your email templates.", "security": [ { "ApiKeyAuth": [] @@ -6522,144 +16979,116 @@ }, { "OAuth2": [ - "emails:send" + "templates:read" ] } ], - "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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchSendBody" - } - } - }, - "required": true - }, "responses": { "200": { + "description": "Template", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchSendResponse" - } - } - }, - "description": "All entries sent" - }, - "207": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchSendResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Template" + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Partial success — at least one entry failed" + } }, "400": { + "description": "Validation error", "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": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "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": { + "404": { + "description": "Resource not found", "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": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "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." + } } - }, + } + }, + "patch": { + "operationId": "updateTemplate", + "tags": [ + "Templates" + ], + "summary": "Update a template", + "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.", "security": [ { "ApiKeyAuth": [] @@ -6669,103 +17098,136 @@ }, { "OAuth2": [ - "emails:send" + "templates:write" ] } ], - "summary": "Send a batch of emails", - "tags": [ - "Emails" - ] - } - }, - "/api/emails/{id}": { - "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", "parameters": [ { - "in": "path", - "name": "id", - "required": true, "schema": { - "format": "uuid", - "type": "string" + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTemplate" + } } } - ], + }, "responses": { "200": { + "description": "Updated template", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailGetResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Template" + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Email" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "delete": { + "operationId": "deleteTemplate", + "tags": [ + "Templates" + ], + "summary": "Delete a template", + "description": "Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.", "security": [ { "ApiKeyAuth": [] @@ -6775,467 +17237,445 @@ }, { "OAuth2": [ - "emails:read" + "templates:write" ] } ], - "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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "Template deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailGetResponse" + "$ref": "#/components/schemas/IdResponse" } } - }, - "description": "Email cancelled" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } }, "409": { + "description": "Template still in use", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Email already past PENDING" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/templates": { + "get": { + "operationId": "v1ListTemplates", + "tags": [ + "Templates" + ], + "summary": "List templates", + "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.", "security": [ { "ApiKeyAuth": [] }, { "SessionAuth": [] + }, + { + "OAuth2": [ + "templates:read" + ] } ], - "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?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.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", "minLength": 1, - "type": "string" - } + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Case-insensitive substring match on the name." + }, + "required": false, + "description": "Case-insensitive substring match on the name.", + "name": "search", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ] + }, + "required": false, + "name": "email_category", + "in": "query" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListSubscribe" - } - } - }, - "required": true - }, "responses": { "200": { + "description": "Template list", "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/TemplateV1List" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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": { + "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." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "post": { + "operationId": "v1CreateTemplate", + "tags": [ + "Templates" + ], + "summary": "Create a template", + "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.", "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" - } + "OAuth2": [ + "templates:write" + ] } ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListUnsubscribe" + "$ref": "#/components/schemas/TemplateV1Create" } } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListUnsubscribeResponse" - } - } - }, - "description": "Contact unsubscribed" - }, - "400": { + } + }, + "responses": { + "201": { + "description": "The created template", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/TemplateV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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" + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/templates/{id}": { + "get": { + "operationId": "v1GetTemplate", + "tags": [ + "Templates" + ], + "summary": "Retrieve a template", + "description": "Fetch one template by id.\n\nRequires the `templates:read` scope — View your email templates.", "security": [ { "ApiKeyAuth": [] }, { "SessionAuth": [] + }, + { + "OAuth2": [ + "templates:read" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], - "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": { + "description": "The template", "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/TemplateV1" } } - }, - "description": "Mailbox list" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "404": { + "description": "`resource_not_found` — no template with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "patch": { + "operationId": "v1UpdateTemplate", + "tags": [ + "Templates" + ], + "summary": "Update a template", + "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.", "security": [ { "ApiKeyAuth": [] @@ -7245,380 +17685,354 @@ }, { "OAuth2": [ - "mailboxes:read" + "templates:write" ] } ], - "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", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateMailboxBody" + "$ref": "#/components/schemas/TemplateV1Update" } } - }, - "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": { + "description": "The updated template", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/TemplateV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "`resource_not_found` — no template with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, - "409": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "The address already exists, the domain is not verified, or the project is at its 10-mailbox limit." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "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." + } } - }, + } + }, + "delete": { + "operationId": "v1DeleteTemplate", + "tags": [ + "Templates" + ], + "summary": "Delete a template", + "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.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "templates: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": { + "type": "string", "format": "uuid", - "type": "string" - } + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "Template deleted", "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" + "$ref": "#/components/schemas/TemplateV1Deleted" } } - }, - "description": "Mailbox deleted" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "404": { + "description": "`resource_not_found` — no template with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "409": { + "description": "`conflict` — the template is still referenced by a workflow step or an active campaign.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/snippets": { + "get": { + "operationId": "listSnippets", + "tags": [ + "Templates" + ], + "summary": "List snippets", + "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.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "templates:read" ] } ], - "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": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { "type": "string" - } + }, + "required": false, + "name": "search", + "in": "query" } ], "responses": { "200": { + "description": "Snippet list", "content": { "application/json": { "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/MailboxDetail" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" + "$ref": "#/components/schemas/SnippetListResponse" } } - }, - "description": "Mailbox with connection settings" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "post": { + "operationId": "createSnippet", + "tags": [ + "Templates" + ], + "summary": "Create a snippet", + "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.", "security": [ { "ApiKeyAuth": [] @@ -7628,121 +18042,127 @@ }, { "OAuth2": [ - "mailboxes:read" + "templates:write" ] } ], - "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" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSnippet" + } } } - ], + }, "responses": { - "200": { + "201": { + "description": "Snippet created", "content": { "application/json": { "schema": { + "type": "object", "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AppPassword" - }, - "type": "array" - }, "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "$ref": "#/components/schemas/Snippet" } }, "required": [ "success", "data" - ], - "type": "object" + ] } } - }, - "description": "App password list" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "409": { + "description": "A snippet with that name already exists in this project", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/snippets/{id}": { + "get": { + "operationId": "getSnippet", + "tags": [ + "Templates" + ], + "summary": "Get a snippet", + "description": "Requires the `templates:read` scope — View your email templates.", "security": [ { "ApiKeyAuth": [] @@ -7752,889 +18172,767 @@ }, { "OAuth2": [ - "mailboxes:read" + "templates: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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAppPassword" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { + "description": "Snippet", "content": { "application/json": { "schema": { + "type": "object", "properties": { - "data": { - "$ref": "#/components/schemas/AppPasswordReveal" - }, "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "$ref": "#/components/schemas/Snippet" } }, "required": [ "success", "data" - ], - "type": "object" + ] } } - }, - "description": "App password created; the secret is behind the one-time link" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "patch": { + "operationId": "updateSnippet", + "tags": [ + "Templates" + ], + "summary": "Update a snippet", + "description": "Requires the `templates:write` scope — Create, edit, and delete your email templates.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "templates: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", + "type": "string", + "format": "uuid" + }, "required": true, - "schema": { - "format": "uuid", - "type": "string" - } + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSnippet" + } + } + } + }, "responses": { "200": { + "description": "Updated snippet", "content": { "application/json": { "schema": { + "type": "object", "properties": { - "data": { - "properties": { - "revoked": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "revoked" - ], - "type": "object" - }, "success": { + "type": "boolean", "enum": [ true - ], - "type": "boolean" + ] + }, + "data": { + "$ref": "#/components/schemas/Snippet" } }, "required": [ "success", "data" - ], - "type": "object" + ] } } - }, - "description": "App password revoked" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } + }, + "409": { + "description": "A snippet with that name already exists in this project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "delete": { + "operationId": "deleteSnippet", + "tags": [ + "Templates" + ], + "summary": "Delete a snippet", + "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.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "templates:write" ] } ], - "summary": "Revoke an app password", - "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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "Snippet deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiKeyListResponse" + "$ref": "#/components/schemas/IdResponse" } } - }, - "description": "API key list" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/webhooks": { + "get": { + "operationId": "listWebhooks", + "tags": [ + "Webhooks" + ], + "summary": "List user webhooks", + "description": "List all user-managed outbound webhooks for the auth'd project (secrets are not returned).\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "api-keys:read" + "webhooks: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 `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", - "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": { + "200": { + "description": "Webhook list", "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/WebhookListResponse" } } - }, - "description": "API key created; the secret is behind the reveal link." + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "post": { + "operationId": "createWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Create a webhook", + "description": "Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "api-keys:write" + "webhooks: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" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhook" + } } } - ], + }, "responses": { - "200": { + "201": { + "description": "Webhook created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessEmpty" + "$ref": "#/components/schemas/WebhookCreateResponse" } } - }, - "description": "API key revoked" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "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": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/webhooks/{id}": { + "get": { + "operationId": "getWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Get a webhook", + "description": "Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "api-keys:write" + "webhooks:read" ] } ], - "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", + "type": "string", + "format": "uuid" + }, "required": true, - "schema": { - "description": "API key id.", - "type": "string" - } + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "Webhook", "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/WebhookGetResponse" } } - }, - "description": "API key rotated; the new secret is behind the reveal link." + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "patch": { + "operationId": "updateWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Update a webhook", + "description": "Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "api-keys:write" + "webhooks:write" ] } ], - "summary": "Rotate an API key's secret", - "tags": [ - "API Keys" - ] - } - }, - "/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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhook" + } + } + } + }, "responses": { "200": { + "description": "Webhook updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionListResponse" + "$ref": "#/components/schemas/WebhookGetResponse" } } - }, - "description": "Suppression list" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "delete": { + "operationId": "deleteWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Delete a webhook", + "description": "Hard-delete a webhook. Cascades to all WebhookCall rows.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", "security": [ { "ApiKeyAuth": [] @@ -8644,90 +18942,103 @@ }, { "OAuth2": [ - "suppression:read" + "webhooks:write" ] } ], - "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 - }, + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { - "201": { + "200": { + "description": "Webhook deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Suppression" + "$ref": "#/components/schemas/SuccessEmpty" } } - }, - "description": "Suppression added" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/webhooks/{id}/rotate-secret": { + "post": { + "operationId": "rotateWebhookSecret", + "tags": [ + "Webhooks" + ], + "summary": "Rotate the webhook signing secret", + "description": "Generate a new shared secret. Returns the new plaintext secret exactly once.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", "security": [ { "ApiKeyAuth": [] @@ -8737,87 +19048,103 @@ }, { "OAuth2": [ - "suppression:write" + "webhooks: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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { - "204": { - "description": "Suppression removed" + "200": { + "description": "Secret rotated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookRotateSecretResponse" + } + } + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/webhooks/{id}/calls": { + "get": { + "operationId": "listWebhookCalls", + "tags": [ + "Webhooks" + ], + "summary": "List recent webhook calls", + "description": "Cursor-paginated list of recent delivery attempts for a single webhook.\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", "security": [ { "ApiKeyAuth": [] @@ -8827,92 +19154,122 @@ }, { "OAuth2": [ - "suppression:write" + "webhooks:read" ] } ], - "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", + "schema": { + "type": "string", + "format": "uuid" + }, "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { "schema": { - "description": "URL-encoded email address", "type": "string" - } + }, + "required": false, + "name": "cursor", + "in": "query" } ], "responses": { "200": { + "description": "Webhook call history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionCheckResponse" + "$ref": "#/components/schemas/WebhookCallsListResponse" } } - }, - "description": "Suppression check result" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/webhooks": { + "get": { + "operationId": "v1ListWebhooks", + "tags": [ + "Webhooks" + ], + "summary": "List webhooks", + "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.", "security": [ { "ApiKeyAuth": [] @@ -8922,135 +19279,104 @@ }, { "OAuth2": [ - "suppression:read" + "webhooks: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, + "type": "integer", "minimum": 1, - "type": "integer" - } - }, - { - "in": "query", - "name": "cursor", + "maximum": 100, + "default": 20 + }, "required": false, - "schema": { - "minLength": 1, - "type": "string" - } + "name": "limit", + "in": "query" }, { - "in": "query", - "name": "search", - "required": false, "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "type", + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, "required": false, - "schema": { - "enum": [ - "MARKETING", - "TRANSACTIONAL", - "HEADLESS" - ], - "type": "string" - } + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" } ], "responses": { "200": { + "description": "Webhook list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateListResponse" - } - } - }, - "description": "Template list" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/WebhookV1List" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "post": { + "operationId": "v1CreateWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Create a webhook", + "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.", "security": [ { "ApiKeyAuth": [] @@ -9060,115 +19386,92 @@ }, { "OAuth2": [ - "templates:read" + "webhooks:write" ] } ], - "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": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTemplate" + "$ref": "#/components/schemas/WebhookV1Create" } } - }, - "required": true + } }, "responses": { "201": { + "description": "The created webhook and its one-time signing secret", "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" + "$ref": "#/components/schemas/WebhookV1Created" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/webhooks/{id}": { + "get": { + "operationId": "v1GetWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Retrieve a webhook", + "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.", "security": [ { "ApiKeyAuth": [] @@ -9178,113 +19481,103 @@ }, { "OAuth2": [ - "templates:write" + "webhooks:read" ] } ], - "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": { + "type": "string", "format": "uuid", - "type": "string" - } + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "The webhook", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdResponse" - } - } - }, - "description": "Template deleted" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/WebhookV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, - "409": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Template still in use" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "patch": { + "operationId": "v1UpdateWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Update a webhook", + "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.", "security": [ { "ApiKeyAuth": [] @@ -9294,116 +19587,113 @@ }, { "OAuth2": [ - "templates:write" + "webhooks: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": { + "type": "string", "format": "uuid", - "type": "string" - } + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookV1Update" + } + } + } + }, "responses": { "200": { + "description": "The updated webhook", "content": { "application/json": { "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Template" - }, - "success": { - "enum": [ - true - ], - "type": "boolean" - } - }, - "required": [ - "success", - "data" - ], - "type": "object" + "$ref": "#/components/schemas/WebhookV1" } } - }, - "description": "Template" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "404": { + "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "delete": { + "operationId": "v1DeleteWebhook", + "tags": [ + "Webhooks" + ], + "summary": "Delete a webhook", + "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.", "security": [ { "ApiKeyAuth": [] @@ -9413,136 +19703,105 @@ }, { "OAuth2": [ - "templates:read" + "webhooks:write" ] } ], - "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": { + "type": "string", "format": "uuid", - "type": "string" - } + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateTemplate" - } - } - }, - "required": true - }, "responses": { "200": { + "description": "Webhook deleted", "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/WebhookV1Deleted" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "404": { + "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation failed — request body or query parameters did not match the schema" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/webhooks/{id}/rotate-secret": { + "post": { + "operationId": "v1RotateWebhookSecret", + "tags": [ + "Webhooks" + ], + "summary": "Rotate a webhook signing secret", + "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.", "security": [ { "ApiKeyAuth": [] @@ -9552,566 +19811,613 @@ }, { "OAuth2": [ - "templates:write" + "webhooks: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 - }, + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Resource id." + }, + "required": true, + "description": "Resource id.", + "name": "id", + "in": "path" + } + ], "responses": { "200": { + "description": "The new signing secret and the moment the previous one stops verifying", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TrackEventResponse" + "$ref": "#/components/schemas/WebhookV1SecretRotated" } } - }, - "description": "Event tracked" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "404": { + "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } + }, + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/projects/{id}/api-keys": { + "post": { + "operationId": "createApiKey", + "tags": [ + "API Keys" + ], + "summary": "Create an API key", + "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.", "security": [ { - "ApiKeyAuth": [] + "SessionAuth": [] }, { "OAuth2": [ - "events:write" + "api-keys: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", + "parameters": [ + { + "schema": { + "type": "string", + "description": "Project id." + }, + "required": true, + "description": "Project id.", + "name": "id", + "in": "path" + } + ], "requestBody": { + "required": true, "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" + "$ref": "#/components/schemas/CreateApiKeyBody" } } - }, - "required": true + } }, "responses": { "201": { + "description": "API key created; the secret is behind the reveal link.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRecord" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiKey" + }, + { + "type": "object", + "properties": { + "revealUrl": { + "type": "string", + "format": "uri", + "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." + }, + "revealExpiresAt": { + "type": "string", + "format": "date-time", + "description": "When the reveal link stops working. Create or rotate again to get a new one." + } + }, + "required": [ + "revealUrl", + "revealExpiresAt" + ] + } + ], + "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." + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Project created" + } }, "400": { + "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Validation error" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "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": { + "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - }, - "description": "Internal server error" + } } - }, + } + }, + "get": { + "operationId": "listApiKeys", + "tags": [ + "API Keys" + ], + "summary": "List API keys for a project", + "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.", "security": [ { "SessionAuth": [] }, { "OAuth2": [ - "projects:write" + "api-keys:read" ] } ], - "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" - ] - } + "type": "string", + "description": "Project id." + }, + "required": true, + "description": "Project id.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "API key list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsCampaignStatsV1" + "$ref": "#/components/schemas/ApiKeyListResponse" } } - }, - "description": "Campaign statistics" + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/projects/{id}/api-keys/{keyId}/rotate": { + "post": { + "operationId": "rotateApiKey", + "tags": [ + "API Keys" + ], + "summary": "Rotate an API key's secret", + "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.", "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "analytics:read" + "api-keys:write" ] } ], - "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" - ] - } + "type": "string", + "description": "Project id." + }, + "required": true, + "description": "Project id.", + "name": "id", + "in": "path" }, { - "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" - ] - } + "type": "string", + "description": "API key id." + }, + "required": true, + "description": "API key id.", + "name": "keyId", + "in": "path" } ], "responses": { "200": { + "description": "API key rotated; the new secret is behind the reveal link.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsTimeseriesV1" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "lastFour": { + "type": "string" + }, + "revealUrl": { + "type": "string", + "format": "uri", + "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." + }, + "revealExpiresAt": { + "type": "string", + "format": "date-time", + "description": "When the reveal link stops working. Create or rotate again to get a new one." + } + }, + "required": [ + "lastFour", + "revealUrl", + "revealExpiresAt" + ] + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Daily time series" + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/projects/{id}/api-keys/{keyId}": { + "delete": { + "operationId": "revokeApiKey", + "tags": [ + "API Keys" + ], + "summary": "Revoke an API key", + "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.", "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "analytics:read" + "api-keys:write" ] } ], - "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" - ] - } + "type": "string", + "description": "Project id." + }, + "required": true, + "description": "Project id.", + "name": "id", + "in": "path" }, { - "in": "query", - "name": "limit", - "required": false, "schema": { - "default": 10, - "maximum": 50, - "minimum": 1, - "type": "integer" - } + "type": "string", + "description": "API key id." + }, + "required": true, + "description": "API key id.", + "name": "keyId", + "in": "path" } ], "responses": { "200": { + "description": "API key revoked", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsTopCampaignsV1" + "$ref": "#/components/schemas/SuccessEmpty" } } - }, - "description": "Ranked campaigns" + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/suppression": { + "get": { + "operationId": "listSuppressions", + "tags": [ + "Suppression" + ], + "summary": "List suppressed emails", + "description": "Cursor-paginated list of suppressed addresses. Filter by `reason`.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.", "security": [ { "ApiKeyAuth": [] @@ -10121,106 +20427,115 @@ }, { "OAuth2": [ - "analytics:read" + "suppression: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, + "type": "integer", "minimum": 1, - "type": "integer" - } + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" }, { - "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" - } + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ] + }, + "required": false, + "name": "reason", + "in": "query" } ], "responses": { "200": { + "description": "Suppression list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1List" + "$ref": "#/components/schemas/SuppressionListResponse" } } - }, - "description": "Campaign list" + } }, - "401": { + "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, - "403": { + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "422": { + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "post": { + "operationId": "addSuppression", + "tags": [ + "Suppression" + ], + "summary": "Manually add an email to the suppression list", + "description": "The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.", "security": [ { "ApiKeyAuth": [] @@ -10230,124 +20545,92 @@ }, { "OAuth2": [ - "campaigns:read" + "suppression:write" ] } ], - "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": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Create" + "$ref": "#/components/schemas/AddSuppression" } } - }, - "required": true + } }, "responses": { "201": { + "description": "Suppression added", "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" + "$ref": "#/components/schemas/Suppression" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "404": { + "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`resource_not_found` — `segment_id` names a segment that does not belong to this project." + } }, - "409": { + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly." + } }, - "422": { + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "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": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/suppression/{email}": { + "get": { + "operationId": "checkSuppression", + "tags": [ + "Suppression" + ], + "summary": "Check whether an email is suppressed", + "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.", "security": [ { "ApiKeyAuth": [] @@ -10357,115 +20640,92 @@ }, { "OAuth2": [ - "campaigns:write" + "suppression:read" ] } ], - "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" - } + "type": "string", + "description": "URL-encoded email address" + }, + "required": true, + "description": "URL-encoded email address", + "name": "email", + "in": "path" } ], "responses": { "200": { + "description": "Suppression check result", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Deleted" + "$ref": "#/components/schemas/SuppressionCheckResponse" } } - }, - "description": "Campaign deleted" + } }, "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — only `DRAFT` campaigns can be deleted." + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "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": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "delete": { + "operationId": "removeSuppression", + "tags": [ + "Suppression" + ], + "summary": "Remove an email from the suppression list", + "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.", "security": [ { "ApiKeyAuth": [] @@ -10475,103 +20735,87 @@ }, { "OAuth2": [ - "campaigns:write" + "suppression: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": { - "description": "Resource id.", - "format": "uuid", - "type": "string" - } + "type": "string", + "description": "URL-encoded email address" + }, + "required": true, + "description": "URL-encoded email address", + "name": "email", + "in": "path" } ], "responses": { - "200": { + "204": { + "description": "Suppression removed" + }, + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "$ref": "#/components/schemas/Error" } } - }, - "description": "The campaign" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "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": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/suppressions": { + "get": { + "operationId": "v1ListSuppressions", + "tags": [ + "Suppression" + ], + "summary": "List suppressed addresses", + "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.", "security": [ { "ApiKeyAuth": [] @@ -10581,123 +20825,120 @@ }, { "OAuth2": [ - "campaigns:read" + "suppression:read" ] } ], - "summary": "Retrieve a campaign", - "tags": [ - "Campaigns" - ] - }, - "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" - } + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "description": "Filter to one reason. Omit for every suppressed address." + }, + "required": false, + "description": "Filter to one reason. Omit for every suppressed address.", + "name": "reason", + "in": "query" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CampaignV1Update" - } - } - }, - "required": true - }, "responses": { "200": { + "description": "Suppression list", "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/SuppressionV1List" } } - }, - "description": "`validation_error` — the campaign is not in an editable status, or the segment change is not allowed." + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "post": { + "operationId": "v1CreateSuppression", + "tags": [ + "Suppression" + ], + "summary": "Suppress an address", + "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.", "security": [ { "ApiKeyAuth": [] @@ -10707,115 +20948,92 @@ }, { "OAuth2": [ - "campaigns:write" + "suppression:write" ] } ], - "summary": "Update a campaign", - "tags": [ - "Campaigns" - ] - } - }, - "/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" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuppressionV1Create" + } } } - ], + }, "responses": { - "200": { + "201": { + "description": "The suppressed address", "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/SuppressionV1" } } - }, - "description": "`validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled." + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/suppressions/{email}": { + "get": { + "operationId": "v1GetSuppression", + "tags": [ + "Suppression" + ], + "summary": "Check whether an address is suppressed", + "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.", "security": [ { "ApiKeyAuth": [] @@ -10825,115 +21043,103 @@ }, { "OAuth2": [ - "campaigns:write" + "suppression:read" ] } ], - "summary": "Cancel a campaign", - "tags": [ - "Campaigns" - ] - } - }, - "/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" - } + "type": "string", + "format": "email", + "description": "The suppressed address, URL-encoded." + }, + "required": true, + "description": "The suppressed address, URL-encoded.", + "name": "email", + "in": "path" } ], "responses": { "200": { + "description": "The suppression record", "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/SuppressionV1" } } - }, - "description": "`validation_error` — only a `SENDING` campaign can be paused." + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "404": { + "description": "`resource_not_found` — this address is not suppressed for the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "delete": { + "operationId": "v1DeleteSuppression", + "tags": [ + "Suppression" + ], + "summary": "Remove an address from the suppression list", + "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.", "security": [ { "ApiKeyAuth": [] @@ -10943,265 +21149,187 @@ }, { "OAuth2": [ - "campaigns:write" + "suppression:write" ] } ], - "summary": "Pause a sending campaign", - "tags": [ - "Campaigns" - ] - } - }, - "/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" - } + "type": "string", + "format": "email", + "description": "The suppressed address, URL-encoded." + }, + "required": true, + "description": "The suppressed address, URL-encoded.", + "name": "email", + "in": "path" } ], "responses": { "200": { + "description": "Address removed from the suppression list", "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/SuppressionV1Deleted" } } - }, - "description": "`validation_error` — only a `PAUSED` campaign can be resumed." + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "SessionAuth": [] - }, - { - "OAuth2": [ - "campaigns:write" - ] + } } - ], - "summary": "Resume a paused campaign", - "tags": [ - "Campaigns" - ] + } } }, - "/api/v1/campaigns/{id}/send": { + "/api/track": { "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" - } + "operationId": "trackEvent", + "tags": [ + "Events" + ], + "summary": "Track a custom event for a contact", + "description": "Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected.\n\nRequires the `events:write` scope — Record custom events for your contacts.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "OAuth2": [ + "events:write" + ] } ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Send" + "$ref": "#/components/schemas/TrackEvent" } } - }, - "required": false + } }, "responses": { "200": { + "description": "Event tracked", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "$ref": "#/components/schemas/TrackEventResponse" } } - }, - "description": "The campaign, now `SENDING` or `SCHEDULED`" + } }, "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/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": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "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." - }, - "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": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "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": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/events": { + "get": { + "operationId": "v1ListEvents", + "tags": [ + "Events" + ], + "summary": "List events", + "description": "Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series.\n\nA cursor is bound to the filters it was issued under: pairing page 2's `next_cursor` with a different `event_name` answers 422 rather than returning a page that belongs to neither query.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", "security": [ { "ApiKeyAuth": [] @@ -11211,105 +21339,116 @@ }, { "OAuth2": [ - "campaigns:send" + "events:read" ] } ], - "summary": "Send or schedule a campaign", - "tags": [ - "Campaigns" - ] - } - }, - "/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", "parameters": [ { - "description": "Resource id.", - "in": "path", - "name": "id", - "required": true, "schema": { - "description": "Resource id.", - "format": "uuid", - "type": "string" - } + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Return only events with this exact name." + }, + "required": false, + "description": "Return only events with this exact name.", + "name": "event_name", + "in": "query" } ], "responses": { "200": { + "description": "Event list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Stats" + "$ref": "#/components/schemas/EventV1List" } } - }, - "description": "Campaign statistics" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "post": { + "operationId": "v1TrackEvent", + "tags": [ + "Events" + ], + "summary": "Record an event", + "description": "Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call.\n\n`contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event.\n\nReserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected with 422: they are written by Sendly's own pipeline and accepting them from a caller would corrupt the series segments read.\n\nThis endpoint does NOT require an `Idempotency-Key`. Events are append-only and the highest-volume write on the surface; a duplicate is a data-quality question for the caller, not a money-path hazard.\n\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.", "security": [ { "ApiKeyAuth": [] @@ -11319,251 +21458,187 @@ }, { "OAuth2": [ - "campaigns:read" + "events:write" ] } ], - "summary": "Retrieve campaign statistics", - "tags": [ - "Campaigns" - ] - } - }, - "/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": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SendEmailV1" + "$ref": "#/components/schemas/EventTrackV1" } } - }, - "required": true + } }, "responses": { - "202": { + "201": { + "description": "Event recorded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailV1" + "$ref": "#/components/schemas/EventV1" } } - }, - "description": "Email queued" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "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": { + "description": "`resource_not_found` — no contact with this id in the authenticated project.", "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." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "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": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "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": [ - "emails:send" - ] + } } - ], - "summary": "Send a transactional email", - "tags": [ - "Emails" - ] + } } }, - "/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/events/names": { + "get": { + "operationId": "v1ListEventNames", + "tags": [ + "Events" + ], + "summary": "List event names", + "description": "Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", + "security": [ + { + "ApiKeyAuth": [] }, - "required": true - }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "events:read" + ] + } + ], "responses": { - "202": { + "200": { + "description": "Event names", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTestV1" + "$ref": "#/components/schemas/EventNamesV1" } } - }, - "description": "Test email queued" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "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." - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "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." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "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": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "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." + } } - }, + } + } + }, + "/api/v1/events/stats": { + "get": { + "operationId": "v1GetEventStats", + "tags": [ + "Events" + ], + "summary": "Retrieve event counts", + "description": "Per-name event counts over a bounded window, most frequent first.\n\nThe window defaults to the last 30 days and never reaches further back than 90: this is a GROUP BY over the highest-volume table in the system, and an all-time answer is not one it can keep giving at scale. A wider request is narrowed rather than refused, and the `window` field states the range actually covered.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", "security": [ { "ApiKeyAuth": [] @@ -11573,118 +21648,112 @@ }, { "OAuth2": [ - "emails:test" + "events:read" ] } ], - "summary": "Send a sandbox test email", - "tags": [ - "Emails" - ] - } - }, - "/api/v1/events": { - "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", "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", + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." + }, "required": false, - "schema": { - "description": "Opaque cursor from a previous response's `next_cursor`.", - "minLength": 1, - "type": "string" - } + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", + "in": "query" }, { - "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" - } + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." + }, + "required": false, + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", + "in": "query" } ], "responses": { "200": { + "description": "Event counts", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventV1List" + "$ref": "#/components/schemas/EventStatsV1" } } - }, - "description": "Event list" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/analytics/timeseries": { + "get": { + "operationId": "v1GetAnalyticsTimeseries", + "tags": [ + "Analytics" + ], + "summary": "Retrieve the daily email time series", + "description": "Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", "security": [ { "ApiKeyAuth": [] @@ -11694,100 +21763,112 @@ }, { "OAuth2": [ - "events:read" + "analytics:read" ] } ], - "summary": "List events", - "tags": [ - "Events" - ] - }, - "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" - } - } + "parameters": [ + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." + }, + "required": false, + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", + "in": "query" }, - "required": true - }, + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." + }, + "required": false, + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", + "in": "query" + } + ], "responses": { - "201": { + "200": { + "description": "Daily time series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventV1" + "$ref": "#/components/schemas/AnalyticsTimeseriesV1" } } - }, - "description": "Event recorded" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 contact with this id in the authenticated project." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/analytics/campaigns": { + "get": { + "operationId": "v1GetCampaignAnalytics", + "tags": [ + "Analytics" + ], + "summary": "Retrieve campaign totals and engagement", + "description": "Campaign counts plus average open and click rates.\n\n`total` and `active` count campaigns CREATED in the window; `completed` counts campaigns SENT in it — so a campaign created earlier and sent inside the window appears only in `completed`. Rates are percentages to one decimal place, averaged over the campaigns sent in the window.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", "security": [ { "ApiKeyAuth": [] @@ -11797,82 +21878,112 @@ }, { "OAuth2": [ - "events:write" + "analytics:read" ] } ], - "summary": "Record an event", - "tags": [ - "Events" - ] - } - }, - "/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", + "parameters": [ + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." + }, + "required": false, + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", + "in": "query" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." + }, + "required": false, + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", + "in": "query" + } + ], "responses": { "200": { + "description": "Campaign statistics", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventNamesV1" + "$ref": "#/components/schemas/AnalyticsCampaignStatsV1" } } - }, - "description": "Event names" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/analytics/top-campaigns": { + "get": { + "operationId": "v1ListTopCampaigns", + "tags": [ + "Analytics" + ], + "summary": "List the best-performing campaigns", + "description": "Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", "security": [ { "ApiKeyAuth": [] @@ -11882,112 +21993,123 @@ }, { "OAuth2": [ - "events:read" + "analytics:read" ] } ], - "summary": "List event names", - "tags": [ - "Events" - ] - } - }, - "/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", "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" - ] - } + ], + "format": "date-time", + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." + }, + "required": false, + "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", + "name": "from", + "in": "query" }, { - "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" - ] - } + ], + "format": "date-time", + "description": "End of the window (ISO 8601). Defaults to now." + }, + "required": false, + "description": "End of the window (ISO 8601). Defaults to now.", + "name": "to", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10 + }, + "required": false, + "name": "limit", + "in": "query" } ], "responses": { "200": { + "description": "Ranked campaigns", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventStatsV1" + "$ref": "#/components/schemas/AnalyticsTopCampaignsV1" } } - }, - "description": "Event counts" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/deliverability/diagnose": { + "get": { + "operationId": "v1DiagnoseDeliverability", + "tags": [ + "Deliverability" + ], + "summary": "Diagnose why mail from a domain is not arriving", + "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.", "security": [ { "ApiKeyAuth": [] @@ -11997,92 +22119,119 @@ }, { "OAuth2": [ - "events:read" + "deliverability:read" ] } ], - "summary": "Retrieve event counts", - "tags": [ - "Events" - ] - } - }, - "/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", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 253, + "description": "A sending domain in this project, e.g. `example.com`." + }, + "required": true, + "description": "A sending domain in this project, e.g. `example.com`.", + "name": "domain", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "email", + "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." + }, + "required": false, + "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.", + "name": "address", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 30, + "description": "How far back the delivery counters look. 1–30 days; defaults to 7." + }, + "required": false, + "description": "How far back the delivery counters look. 1–30 days; defaults to 7.", + "name": "window_days", + "in": "query" + } + ], "responses": { "200": { + "description": "The diagnosis, with findings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectV1" + "$ref": "#/components/schemas/DeliverabilityDiagnosisV1" } } - }, - "description": "The authenticated project" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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` — the project was deleted between authentication and this read." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/deliverability/domains": { + "get": { + "operationId": "v1ListRecipientDomainStats", + "tags": [ + "Deliverability" + ], + "summary": "Delivery outcomes per recipient domain", + "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.", "security": [ { "ApiKeyAuth": [] @@ -12092,106 +22241,130 @@ }, { "OAuth2": [ - "projects:read" + "deliverability:read" ] } ], - "summary": "Retrieve the authenticated project", - "tags": [ - "Projects" - ] - } - }, - "/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", "parameters": [ { - "in": "query", - "name": "limit", - "required": false, "schema": { - "default": 20, - "maximum": 100, + "type": "integer", "minimum": 1, - "type": "integer" - } + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" }, { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." + }, + "required": false, "description": "Opaque cursor from a previous response's `next_cursor`.", - "in": "query", "name": "after", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 30, + "description": "How far back to read. 1-30 days; defaults to 30, which is the window the job maintains." + }, "required": false, + "description": "How far back to read. 1-30 days; defaults to 30, which is the window the job maintains.", + "name": "days", + "in": "query" + }, + { "schema": { - "description": "Opaque cursor from a previous response's `next_cursor`.", + "type": "string", "minLength": 1, - "type": "string" - } + "maxLength": 253, + "description": "Restrict to one recipient domain." + }, + "required": false, + "description": "Restrict to one recipient domain.", + "name": "domain", + "in": "query" } ], "responses": { "200": { + "description": "Cursor-paginated recipient-domain rollup", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1List" + "$ref": "#/components/schemas/RecipientDomainStatsV1List" } } - }, - "description": "Segment list" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/deliverability/dmarc": { + "get": { + "operationId": "v1ListDmarcReports", + "tags": [ + "Deliverability" + ], + "summary": "DMARC aggregate reports for your domains", + "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.", "security": [ { "ApiKeyAuth": [] @@ -12201,100 +22374,130 @@ }, { "OAuth2": [ - "segments:read" + "deliverability:read" ] } ], - "summary": "List segments", - "tags": [ - "Segments" - ] - }, - "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" - } - } + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SegmentV1" - } - } + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Opaque cursor from a previous response's `next_cursor`." }, - "description": "Segment created" + "required": false, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "name": "after", + "in": "query" }, - "400": { + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 180, + "description": "How far back to read, by the report's window start. 1-180 days; defaults to 30." + }, + "required": false, + "description": "How far back to read, by the report's window start. 1-180 days; defaults to 30.", + "name": "days", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "description": "Restrict to reports about one of your domains." + }, + "required": false, + "description": "Restrict to reports about one of your domains.", + "name": "domain", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Cursor-paginated DMARC aggregate reports", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/DmarcReportV1List" } } - }, - "description": "`validation_error` — a `DYNAMIC` segment was submitted without a `condition`." + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/usage": { + "get": { + "operationId": "v1GetUsage", + "tags": [ + "Usage" + ], + "summary": "Retrieve current usage and limits", + "description": "Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling.\n\nEvery figure is read from an enforcement path, so what this reports and what refuses a send cannot disagree. Correspondingly, nothing else is published — there is no billing period, invoice total or non-email meter here, because the platform meters none of those.\n\nTwo caveats worth reading before you alert on these numbers:\n\n- The windows differ. The monthly counters roll over on the SERVER's calendar month; the daily counter buckets on the UTC date. The two therefore reset at different instants.\n- `monthly.limit` is null once a subscription makes sending metered rather than capped, and also when an operator has set per-category limits — in that case the caps live in `monthly.categories[*].limit`.\n\nRequires the `usage:read` scope — View your usage totals and billing limits.", "security": [ { "ApiKeyAuth": [] @@ -12304,115 +22507,82 @@ }, { "OAuth2": [ - "segments:write" + "usage:read" ] } ], - "summary": "Create a segment", - "tags": [ - "Segments" - ] - } - }, - "/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", - "parameters": [ - { - "description": "Resource id.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Resource id.", - "format": "uuid", - "type": "string" - } - } - ], "responses": { "200": { + "description": "Current usage", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1Deleted" + "$ref": "#/components/schemas/UsageV1" } } - }, - "description": "Segment deleted" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 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": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/projects": { + "get": { + "operationId": "v1GetProject", + "tags": [ + "Projects" + ], + "summary": "Retrieve the authenticated project", + "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.", "security": [ { "ApiKeyAuth": [] @@ -12422,103 +22592,92 @@ }, { "OAuth2": [ - "segments:write" + "projects:read" ] } ], - "summary": "Delete a segment", - "tags": [ - "Segments" - ] - }, - "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" - } - } - ], "responses": { "200": { + "description": "The authenticated project", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1" + "$ref": "#/components/schemas/ProjectV1" } } - }, - "description": "The segment" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "404": { + "description": "`resource_not_found` — the project was deleted between authentication and this read.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`resource_not_found` — no segment with this id belongs to the authenticated project." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/mailboxes": { + "get": { + "operationId": "listMailboxes", + "tags": [ + "Mailboxes" + ], + "summary": "List mailboxes", + "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.", "security": [ { "ApiKeyAuth": [] @@ -12528,113 +22687,235 @@ }, { "OAuth2": [ - "segments:read" + "mailboxes:read" ] } ], - "summary": "Retrieve a segment", - "tags": [ - "Segments" - ] + "responses": { + "200": { + "description": "Mailbox list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mailbox" + } + } + }, + "required": [ + "success", + "data" + ] + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid auth", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden — insufficient permissions or project disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limit or billing limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } }, - "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", - "parameters": [ + "post": { + "operationId": "createMailbox", + "tags": [ + "Mailboxes" + ], + "summary": "Create a mailbox", + "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.", + "security": [ { - "description": "Resource id.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Resource id.", - "format": "uuid", - "type": "string" - } + "SessionAuth": [] + }, + { + "OAuth2": [ + "mailboxes:write" + ] } ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1Update" + "$ref": "#/components/schemas/CreateMailboxBody" + } + } + } + }, + "responses": { + "201": { + "description": "Mailbox provisioned", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/Mailbox" + } + }, + "required": [ + "success", + "data" + ] + } } } }, - "required": true - }, - "responses": { - "200": { + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1" + "$ref": "#/components/schemas/Error" } } - }, - "description": "The updated segment" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`resource_not_found` — no segment with this id belongs to the authenticated project." + } }, - "422": { + "409": { + "description": "The address already exists, the domain is not verified, or the project is at its 10-mailbox limit.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } + }, + "502": { + "description": "Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } - }, + } + } + }, + "/api/mailboxes/{id}": { + "get": { + "operationId": "getMailbox", + "tags": [ + "Mailboxes" + ], + "summary": "Get a mailbox", + "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.", "security": [ { "ApiKeyAuth": [] @@ -12644,212 +22925,245 @@ }, { "OAuth2": [ - "segments:write" + "mailboxes:read" ] } ], - "summary": "Update a segment", - "tags": [ - "Segments" - ] - } - }, - "/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" - } - }, - { - "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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "Mailbox with connection settings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentContactV1List" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/MailboxDetail" + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Segment member list" + } }, - "401": { + "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, - "403": { + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "404": { + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`resource_not_found` — no segment with this id belongs to the authenticated project." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "delete": { + "operationId": "deleteMailbox", + "tags": [ + "Mailboxes" + ], + "summary": "Delete a mailbox", + "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.", "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "segments:read" + "mailboxes:write" ] } ], - "summary": "List the contacts in a segment", - "tags": [ - "Segments" - ] - } - }, - "/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", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { + "description": "Mailbox deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UsageV1" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "deleted" + ] + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Current usage" + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/mailboxes/{id}/drafts": { + "post": { + "operationId": "draftMailboxMessage", + "tags": [ + "Mailboxes" + ], + "summary": "Draft a message with AI", + "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.", "security": [ { "ApiKeyAuth": [] @@ -12859,106 +23173,174 @@ }, { "OAuth2": [ - "usage:read" + "mailboxes:read" ] } ], - "summary": "Retrieve current usage and limits", - "tags": [ - "Usage" - ] - } - }, - "/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": [ { - "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" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DraftMailboxMessage" + } + } + } + }, "responses": { "200": { + "description": "A draft. Nothing was sent.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1List" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "subject": { + "type": [ + "string", + "null" + ], + "description": "Suggested subject, or null." + }, + "body": { + "type": [ + "string", + "null" + ], + "description": "Suggested plain-text body, or null." + }, + "subjects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Alternative subject lines (`subject` mode); empty otherwise." + }, + "sent": { + "type": "boolean", + "enum": [ + false + ], + "description": "Always false. Reported rather than assumed, so a draft cannot be mistaken for a send." + } + }, + "required": [ + "subject", + "body", + "subjects", + "sent" + ] + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Workflow list" + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } + }, + "502": { + "description": "The drafting model was unreachable or returned nothing usable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } - }, + } + } + }, + "/api/mailboxes/{id}/messages": { + "post": { + "operationId": "sendMailboxMessage", + "tags": [ + "Mailboxes" + ], + "summary": "Send a message from a mailbox", + "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.", "security": [ { "ApiKeyAuth": [] @@ -12968,90 +23350,181 @@ }, { "OAuth2": [ - "workflows:read" + "mailboxes:send" ] } ], - "summary": "List workflows", - "tags": [ - "Workflows" - ] - }, - "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", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowCreateV1" + "$ref": "#/components/schemas/ComposeMailboxMessage" } } - }, - "required": true + } }, "responses": { "201": { + "description": "Message submitted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "submitted": { + "type": "boolean", + "enum": [ + true + ] + }, + "conversationId": { + "type": "string", + "format": "uuid", + "description": "The conversation this send started. Replies thread onto it." + }, + "messageId": { + "type": "string", + "format": "uuid", + "description": "The stored outbound message." + } + }, + "required": [ + "submitted", + "conversationId", + "messageId" + ] + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Workflow created" + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "422": { + "description": "The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`).", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } + }, + "502": { + "description": "The mail server refused the submission. Nothing was sent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Message screening could not reach a verdict. Nothing was sent; retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } - }, + } + } + }, + "/api/mailboxes/{id}/app-passwords": { + "get": { + "operationId": "listAppPasswords", + "tags": [ + "Mailboxes" + ], + "summary": "List a mailbox's app passwords", + "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.", "security": [ { "ApiKeyAuth": [] @@ -13061,455 +23534,559 @@ }, { "OAuth2": [ - "workflows:write" + "mailboxes:read" ] } ], - "summary": "Create 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", "parameters": [ { - "description": "Workflow execution id.", - "in": "path", - "name": "execution_id", - "required": true, "schema": { - "description": "Workflow execution id.", - "format": "uuid", - "type": "string" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "App password list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowExecutionV1" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppPassword" + } + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "Cancelled execution" + } }, - "401": { + "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, - "403": { + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "404": { + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`resource_not_found` — no execution with this id in the authenticated project." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "post": { + "operationId": "createAppPassword", + "tags": [ + "Mailboxes" + ], + "summary": "Create an app password", + "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.", "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:write" + "mailboxes:write" ] } ], - "summary": "Cancel a workflow execution", - "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", "parameters": [ { - "description": "Workflow id.", - "in": "path", - "name": "id", - "required": true, "schema": { - "description": "Workflow id.", - "format": "uuid", - "type": "string" - } + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppPassword" + } + } + } + }, "responses": { - "200": { + "201": { + "description": "App password created; the secret is behind the one-time link", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowDeletedV1" - } - } - }, - "description": "Workflow deleted" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/AppPasswordReveal" + } + }, + "required": [ + "success", + "data" + ] } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, - "403": { + "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "404": { + "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`resource_not_found` — no workflow with this id in the authenticated project." + } }, - "409": { + "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`conflict` — the workflow still has running executions." + } }, - "422": { + "404": { + "description": "Resource not found", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/mailboxes/{id}/app-passwords/{passwordId}": { + "delete": { + "operationId": "revokeAppPassword", + "tags": [ + "Mailboxes" + ], + "summary": "Revoke an app password", + "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.", "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:write" + "mailboxes:write" ] } ], - "summary": "Delete a workflow", - "tags": [ - "Workflows" - ] - }, - "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", "parameters": [ { - "description": "Workflow id.", - "in": "path", - "name": "id", + "schema": { + "type": "string", + "format": "uuid" + }, "required": true, + "name": "id", + "in": "path" + }, + { "schema": { - "description": "Workflow id.", - "format": "uuid", - "type": "string" + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "passwordId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "App password revoked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "revoked": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "revoked" + ] + } + }, + "required": [ + "success", + "data" + ] + } + } } - } - ], - "responses": { - "200": { + }, + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1" + "$ref": "#/components/schemas/Error" } } - }, - "description": "Workflow" + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, "404": { + "description": "Resource not found", "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "`resource_not_found` — no workflow with this id in the authenticated project." - }, - "422": { - "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/users/me/projects": { + "post": { + "operationId": "createProject", + "tags": [ + "Projects" + ], + "summary": "Create a project", + "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.", "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:read" + "projects:write" ] } ], - "summary": "Retrieve a workflow", - "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", - "parameters": [ - { - "description": "Workflow id.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Workflow id.", - "format": "uuid", - "type": "string" - } - } - ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowUpdateV1" + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "sesRegion": { + "type": "string", + "enum": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ], + "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed." + } + }, + "required": [ + "name" + ] } } - }, - "required": true + } }, "responses": { - "200": { + "201": { + "description": "Project created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1" + "$ref": "#/components/schemas/ProjectRecord" } } - }, - "description": "Updated workflow" + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "401": { + "description": "Unauthorized — missing or invalid auth", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." + } }, - "404": { + "422": { + "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`resource_not_found` — no workflow with this id in the authenticated project." + } }, - "409": { + "429": { + "description": "Rate limit or billing limit exceeded", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`conflict` — the trigger cannot be changed while executions are running." + } }, - "422": { + "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } + } + } + } + }, + "/api/verify": { + "post": { + "operationId": "verifyEmailAddress", + "tags": [ + "Verify" + ], + "summary": "Validate an email address", + "description": "Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmail" + } + } + } + }, + "responses": { + "200": { + "description": "Verification result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailResponse" + } + } + } }, - "429": { + "400": { + "description": "Validation error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "Internal server error", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/topics": { + "get": { + "operationId": "v1ListTopics", + "tags": [ + "Topics" + ], + "summary": "List topics", + "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.", "security": [ { "ApiKeyAuth": [] @@ -13519,145 +24096,111 @@ }, { "OAuth2": [ - "workflows:write" + "topics:read" ] } ], - "summary": "Update a workflow", - "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", "parameters": [ { - "description": "Workflow id.", - "in": "path", - "name": "id", - "required": true, "schema": { - "description": "Workflow id.", - "format": "uuid", "type": "string" - } + }, + "required": false, + "name": "cursor", + "in": "query" }, { - "in": "query", - "name": "limit", - "required": false, "schema": { - "default": 20, - "maximum": 100, + "type": "integer", "minimum": 1, - "type": "integer" - } - }, - { - "description": "Opaque cursor from a previous response's `next_cursor`.", - "in": "query", - "name": "after", + "maximum": 100 + }, "required": false, - "schema": { - "description": "Opaque cursor from a previous response's `next_cursor`.", - "minLength": 1, - "type": "string" - } + "name": "limit", + "in": "query" }, { - "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" - } + "type": [ + "boolean", + "null" + ] + }, + "required": false, + "name": "include_archived", + "in": "query" } ], "responses": { "200": { + "description": "One page of topics", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowExecutionV1List" + "$ref": "#/components/schemas/TopicListV1" } } - }, - "description": "Execution list" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 workflow with this id in the authenticated project." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "post": { + "operationId": "v1CreateTopic", + "tags": [ + "Topics" + ], + "summary": "Create a topic", + "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.", "security": [ { "ApiKeyAuth": [] @@ -13667,123 +24210,92 @@ }, { "OAuth2": [ - "workflows:read" + "topics:write" ] } ], - "summary": "List a workflow's executions", - "tags": [ - "Workflows" - ] - }, - "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.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Workflow id.", - "format": "uuid", - "type": "string" - } - } - ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowExecutionStartV1" + "$ref": "#/components/schemas/TopicCreateV1" } } - }, - "required": true + } }, "responses": { "201": { + "description": "The created topic", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowExecutionV1" + "$ref": "#/components/schemas/TopicV1" } } - }, - "description": "Execution started" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 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." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + } + }, + "/api/v1/topics/{id}": { + "get": { + "operationId": "v1GetTopic", + "tags": [ + "Topics" + ], + "summary": "Retrieve a topic", + "description": "Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each.", "security": [ { "ApiKeyAuth": [] @@ -13793,119 +24305,92 @@ }, { "OAuth2": [ - "workflows:write" + "topics:read" ] } ], - "summary": "Start a workflow for a contact", - "tags": [ - "Workflows" - ] - } - }, - "/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.", - "operationId": "v1GetWorkflowStats", "parameters": [ { - "description": "Workflow id.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Workflow id.", - "format": "uuid", - "type": "string" - } - }, - { - "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time.", - "in": "query", - "name": "from", - "required": false, "schema": { - "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time.", - "format": "date-time", - "type": [ - "string", - "null" - ] - } + "type": "string", + "description": "The topic." + }, + "required": true, + "description": "The topic.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "The topic", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowStatsV1" + "$ref": "#/components/schemas/TopicV1" } } - }, - "description": "Workflow statistics" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 workflow with this id in the authenticated project." + } }, "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`validation_error` — query, path, or body parameters did not match the schema." + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." + } }, "500": { + "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "`internal_error`." + } } - }, + } + }, + "patch": { + "operationId": "v1UpdateTopic", + "tags": [ + "Topics" + ], + "summary": "Update a topic", + "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.", "security": [ { "ApiKeyAuth": [] @@ -13915,135 +24400,104 @@ }, { "OAuth2": [ - "workflows:read" + "topics:write" ] } ], - "summary": "Retrieve workflow statistics", - "tags": [ - "Workflows" - ] - } - }, - "/api/verify": { - "post": { - "description": "Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier.", - "operationId": "verifyEmailAddress", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The topic." + }, + "required": true, + "description": "The topic.", + "name": "id", + "in": "path" + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyEmail" + "$ref": "#/components/schemas/TopicUpdateV1" } } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyEmailResponse" - } - } - }, - "description": "Verification result" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Validation error" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Internal server error" } }, - "security": [], - "summary": "Validate an email address", - "tags": [ - "Verify" - ] - } - }, - "/api/webhooks": { - "get": { - "description": "List all user-managed outbound webhooks for the auth'd project (secrets are not returned).\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", - "operationId": "listWebhooks", "responses": { "200": { + "description": "The updated topic", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookListResponse" + "$ref": "#/components/schemas/TopicV1" } } - }, - "description": "Webhook list" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/topics/{id}/subscriptions": { + "post": { + "operationId": "v1SetTopicSubscription", + "tags": [ + "Topics" + ], + "summary": "Subscribe or unsubscribe a contact from a topic", + "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.", "security": [ { "ApiKeyAuth": [] @@ -14053,90 +24507,104 @@ }, { "OAuth2": [ - "webhooks:read" + "topics:write" ] } ], - "summary": "List user webhooks", - "tags": [ - "Webhooks" - ] - }, - "post": { - "description": "Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", - "operationId": "createWebhook", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The topic." + }, + "required": true, + "description": "The topic.", + "name": "id", + "in": "path" + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWebhook" + "$ref": "#/components/schemas/TopicSubscribeV1" } } - }, - "required": true + } }, "responses": { - "201": { + "200": { + "description": "The resulting subscription", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookCreateResponse" + "$ref": "#/components/schemas/TopicSubscriptionV1" } } - }, - "description": "Webhook created" + } }, - "400": { + "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Validation error" + } }, - "401": { + "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, - "403": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/contacts/{id}/topics": { + "get": { + "operationId": "v1GetContactTopicPreferences", + "tags": [ + "Topics" + ], + "summary": "Get a contact's topic preferences", + "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.", "security": [ { "ApiKeyAuth": [] @@ -14146,103 +24614,94 @@ }, { "OAuth2": [ - "webhooks:write" + "topics:read" ] } ], - "summary": "Create a webhook", - "tags": [ - "Webhooks" - ] - } - }, - "/api/webhooks/{id}": { - "delete": { - "description": "Hard-delete a webhook. Cascades to all WebhookCall rows.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", - "operationId": "deleteWebhook", "parameters": [ { - "in": "path", - "name": "id", - "required": true, "schema": { - "format": "uuid", - "type": "string" - } + "type": "string", + "description": "The contact." + }, + "required": true, + "description": "The contact.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "The contact's preferences", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessEmpty" - } - } - }, - "description": "Webhook deleted" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ContactTopicPreferencesV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/email-validations": { + "post": { + "operationId": "v1ValidateEmails", + "tags": [ + "Validation" + ], + "summary": "Validate a batch of email addresses", + "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.", "security": [ { "ApiKeyAuth": [] @@ -14252,101 +24711,92 @@ }, { "OAuth2": [ - "webhooks:write" + "validation:write" ] } ], - "summary": "Delete a webhook", - "tags": [ - "Webhooks" - ] - }, - "get": { - "description": "Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", - "operationId": "getWebhook", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "format": "uuid", - "type": "string" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailValidationBatchRequestV1" + } } } - ], + }, "responses": { "200": { + "description": "One verdict per address, in the order they were given", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookGetResponse" - } - } - }, - "description": "Webhook" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EmailValidationBatchV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/lists/{id}/validation-runs": { + "post": { + "operationId": "v1StartListValidationRun", + "tags": [ + "Validation" + ], + "summary": "Validate every address on a list", + "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.", "security": [ { "ApiKeyAuth": [] @@ -14356,111 +24806,94 @@ }, { "OAuth2": [ - "webhooks:read" + "validation:write" ] } ], - "summary": "Get a webhook", - "tags": [ - "Webhooks" - ] - }, - "patch": { - "description": "Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", - "operationId": "updateWebhook", "parameters": [ { - "in": "path", - "name": "id", - "required": true, "schema": { - "format": "uuid", - "type": "string" - } + "type": "string", + "description": "The list to validate." + }, + "required": true, + "description": "The list to validate.", + "name": "id", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateWebhook" - } - } - }, - "required": true - }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookGetResponse" - } - } - }, - "description": "Webhook updated" - }, - "400": { + "202": { + "description": "The run, accepted and queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EmailValidationRunV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/validation-runs/{id}": { + "get": { + "operationId": "v1GetValidationRun", + "tags": [ + "Validation" + ], + "summary": "Retrieve a validation run", + "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.", "security": [ { "ApiKeyAuth": [] @@ -14470,122 +24903,94 @@ }, { "OAuth2": [ - "webhooks:write" + "validation:read" ] } ], - "summary": "Update a webhook", - "tags": [ - "Webhooks" - ] - } - }, - "/api/webhooks/{id}/calls": { - "get": { - "description": "Cursor-paginated list of recent delivery attempts for a single webhook.\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", - "operationId": "listWebhookCalls", "parameters": [ { - "in": "path", - "name": "id", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 50, - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "in": "query", - "name": "cursor", - "required": false, "schema": { - "type": "string" - } + "type": "string", + "description": "The validation run." + }, + "required": true, + "description": "The validation run.", + "name": "id", + "in": "path" } ], "responses": { "200": { + "description": "The run", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookCallsListResponse" - } - } - }, - "description": "Webhook call history" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EmailValidationRunV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" + } } - }, + } + } + }, + "/api/v1/validation-runs/{id}/results": { + "get": { + "operationId": "v1ListValidationRunResults", + "tags": [ + "Validation" + ], + "summary": "List a validation run's results", + "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.", "security": [ { "ApiKeyAuth": [] @@ -14595,197 +25000,120 @@ }, { "OAuth2": [ - "webhooks:read" + "validation:read" ] } ], - "summary": "List recent webhook calls", - "tags": [ - "Webhooks" - ] - } - }, - "/api/webhooks/{id}/rotate-secret": { - "post": { - "description": "Generate a new shared secret. Returns the new plaintext secret exactly once.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", - "operationId": "rotateWebhookSecret", "parameters": [ { - "in": "path", - "name": "id", + "schema": { + "type": "string", + "description": "The validation run." + }, "required": true, + "description": "The validation run.", + "name": "id", + "in": "path" + }, + { "schema": { - "format": "uuid", "type": "string" - } + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/EmailValidationVerdictV1" + }, + { + "description": "Return only results with this verdict — `undeliverable` is the usual filter." + } + ] + }, + "required": false, + "description": "Return only results with this verdict — `undeliverable` is the usual filter.", + "name": "verdict", + "in": "query" } ], "responses": { "200": { + "description": "One page of results", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookRotateSecretResponse" - } - } - }, - "description": "Secret rotated" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EmailValidationResultListV1" } } - }, - "description": "Validation error" + } }, "401": { + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized — missing or invalid auth" + } }, "403": { + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden — insufficient permissions or project disabled" + } }, - "404": { + "422": { + "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Resource not found" + } }, "429": { + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Rate limit or billing limit exceeded" + } }, "500": { + "description": "`internal_error`.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal server error" - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "SessionAuth": [] - }, - { - "OAuth2": [ - "webhooks:write" - ] + } } - ], - "summary": "Rotate the webhook signing secret", - "tags": [ - "Webhooks" - ] + } } } }, - "servers": [ - { - "url": "https://api.sendly.now" - } - ], - "tags": [ - { - "description": "Send transactional email and inspect deliveries.", - "name": "Emails" - }, - { - "description": "Bulk sends to a list, segment, or filtered audience. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors).", - "name": "Campaigns" - }, - { - "description": "Saved audiences — a `DYNAMIC` filter re-evaluated on read, or a `STATIC` membership list. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors).", - "name": "Segments" - }, - { - "description": "Manage subscribers and per-contact custom data.", - "name": "Contacts" - }, - { - "description": "Self-serve list membership. These two endpoints accept sending-only keys so they can back a public subscribe/unsubscribe form; list management itself is dashboard-only.", - "name": "Lists" - }, - { - "description": "Register sending domains and manage SES verification.", - "name": "Domains" - }, - { - "description": "Reusable email templates referenced by send operations.", - "name": "Templates" - }, - { - "description": "User-managed outbound webhooks for email and contact events.", - "name": "Webhooks" - }, - { - "description": "Project-scoped sending/API credentials. All four operations are documented; minting and rotation answer with a one-time reveal link rather than the secret, so a delegated caller never receives a key it could use.", - "name": "API Keys" - }, - { - "description": "Project-scoped suppression list. Hard bounces and complaints land here automatically.", - "name": "Suppression" - }, - { - "description": "Event-triggered automations and the contact runs through them. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors).", - "name": "Workflows" - }, - { - "description": "Track custom contact events from your application.", - "name": "Events" - }, - { - "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": "Current email usage against the monthly and daily limits the platform enforces.", - "name": "Usage" - }, - { - "description": "The project a credential is scoped to, and creating new ones. Configuring an existing project stays a dashboard action.", - "name": "Projects" - }, - { - "description": "Real receiving mailboxes on your verified domains. Creating one changes how that domain's mail is routed; the messages a mailbox receives are not part of this API.", - "name": "Mailboxes" - }, - { - "description": "Open email-validation endpoint (no auth required). Used by the marketing-site verifier.", - "name": "Verify" - } - ], "webhooks": {} } diff --git a/package.json b/package.json index 26b1784..9e6a879 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sendly-sdk", - "version": "1.0.0", + "version": "1.1.0", "description": "Official Sendly TypeScript SDK", "license": "MIT", "type": "module", diff --git a/src/__tests__/campaigns.test.ts b/src/__tests__/campaigns.test.ts index 429a7be..7cf5b9b 100644 --- a/src/__tests__/campaigns.test.ts +++ b/src/__tests__/campaigns.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { SendlyConflictError, SendlyNotFoundError } from "../index"; -import type { CampaignV1 } from "../types"; +import type { CampaignFailureV1, CampaignV1 } from "../types"; import { cursorPage, getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers"; function campaign(id: string): CampaignV1 { @@ -8,6 +8,16 @@ function campaign(id: string): CampaignV1 { return { id, name: `Campaign ${id}`, status: "DRAFT" } as unknown as CampaignV1; } +function failure(id: string): CampaignFailureV1 { + return { + id, + contact_id: `ct_${id}`, + email: `${id}@example.com`, + reason: "HARD_BOUNCE", + failed_at: "2026-09-01T00:00:00.000Z", + }; +} + describe("campaigns resource (/api/v1)", () => { test("create POSTs /api/v1/campaigns and resolves the bare body — no envelope unwrap", async () => { const { client, fetchMock } = makeClient(); @@ -227,4 +237,90 @@ describe("campaigns resource (/api/v1)", () => { expect(error).toBeInstanceOf(SendlyConflictError); expect(error.errorCode).toBe("conflict"); }); + + test("listFailures GETs the failures sub-path and keeps the `total` this list uniquely carries", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + data: [ + { + id: "fail_1", + contact_id: "ct_1", + email: "bounced@example.com", + reason: "HARD_BOUNCE", + failed_at: "2026-09-01T00:00:00.000Z", + }, + ], + has_more: false, + next_cursor: null, + total: 4211, + }), + ); + + const page = await client.campaigns.listFailures("cmp_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/campaigns/cmp_1/failures"); + expect(init.method).toBe("GET"); + // Bare v1 body: no `{ success, data }` unwrap happened, and `total` survives. + expect(page.total).toBe(4211); + expect(page.data[0]?.reason).toBe("HARD_BOUNCE"); + }); + + test("listFailures serializes the cursor query params", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { data: [], has_more: false, next_cursor: null, total: 0 })); + + await client.campaigns.listFailures("cmp_1", { limit: 50, after: "fail_9" }); + + const { url } = getCall(fetchMock); + expect(url).toContain("limit=50"); + expect(url).toContain("after=fail_9"); + }); + + test("listFailuresAll walks two pages, threads the cursor, and stops on the last one", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([failure("fail_1")], "fail_1")) + .mockResolvedValueOnce(cursorPage([failure("fail_2"), failure("fail_3")], null)); + + const seen: string[] = []; + for await (const row of client.campaigns.listFailuresAll("cmp_1")) seen.push(row.id); + + expect(seen).toEqual(["fail_1", "fail_2", "fail_3"]); + expect(fetchMock.mock.calls).toHaveLength(2); + expect(getCall(fetchMock, 0).url).not.toContain("after="); + expect(getCall(fetchMock, 1).url).toContain("/campaigns/cmp_1/failures"); + expect(getCall(fetchMock, 1).url).toContain("after=fail_1"); + }); + + test("retryFailed POSTs retry-failed with no body and resolves the queued count", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "cmp_1", queued: 12 })); + + const ack = await client.campaigns.retryFailed("cmp_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/campaigns/cmp_1/retry-failed"); + expect(init.method).toBe("POST"); + // The route takes no body — sending one would be a contract change. + expect(init.body).toBeUndefined(); + expect(ack).toEqual({ id: "cmp_1", queued: 12 }); + }); + + test("retrying a campaign whose retry is already running surfaces the conflict code", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + problemResponse(409, { + type: "https://docs.sendly.now/errors/conflict", + title: "Conflict", + detail: "A retry is already running for this campaign.", + code: "conflict", + }), + ); + + const error = await rejection(client.campaigns.retryFailed("cmp_1")); + expect(error).toBeInstanceOf(SendlyConflictError); + expect(error.errorCode).toBe("conflict"); + }); }); diff --git a/src/__tests__/contacts.test.ts b/src/__tests__/contacts.test.ts index 331344d..d377a1b 100644 --- a/src/__tests__/contacts.test.ts +++ b/src/__tests__/contacts.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; import { SendlyNotFoundError } from "../index"; -import { getCall, getCallBody, jsonResponse, makeClient } from "./helpers"; +import type { ContactV1 } from "../types"; +import { cursorPage, getCall, getCallBody, jsonResponse, makeClient } from "./helpers"; + +function contactV1(id: string): ContactV1 { + // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter + return { id, email: `${id}@example.com`, subscribed: true } as unknown as ContactV1; +} describe("contacts resource", () => { test("create POSTs /api/contacts and unwraps data", async () => { @@ -72,3 +78,128 @@ describe("contacts resource", () => { expect(init.method).toBe("DELETE"); }); }); + +describe("contacts resource (/api/v1)", () => { + test("createV1 POSTs /api/v1/contacts and resolves the bare body, unwrapping nothing", async () => { + const { client, fetchMock } = makeClient(); + const body = { + 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", + }; + fetchMock.mockResolvedValue(jsonResponse(201, body)); + + const created = await client.contacts.createV1({ email: "x@y.com", custom_fields: { plan: "pro" } }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/contacts"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ email: "x@y.com", custom_fields: { plan: "pro" } }); + // v1 answers a bare body: the whole document reaches the caller, `data` and all. + expect(created).toEqual(body); + }); + + test("createV1 accepts just an email — `subscribed` defaults server-side", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(201, contactV1("con_1"))); + await client.contacts.createV1({ email: "x@y.com" }); + expect(getCallBody(fetchMock)).toEqual({ email: "x@y.com" }); + }); + + test("getV1, updateV1 and deleteV1 build the right verb and path", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "con_1", deleted: true })); + + await client.contacts.getV1("con_1"); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/contacts/con_1"); + expect(getCall(fetchMock).init.method).toBe("GET"); + + fetchMock.mockClear(); + await client.contacts.updateV1("con_1", { custom_fields: { plan: "enterprise" } }); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/contacts/con_1"); + expect(getCall(fetchMock).init.method).toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ custom_fields: { plan: "enterprise" } }); + + fetchMock.mockClear(); + const deleted = await client.contacts.deleteV1("con_1"); + expect(getCall(fetchMock).init.method).toBe("DELETE"); + // Unlike the legacy delete, the acknowledgement is resolved rather than discarded. + expect(deleted).toEqual({ id: "con_1", deleted: true }); + }); + + test("listV1 serializes the search, subscribed and cursor params", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([], null)); + + await client.contacts.listV1({ limit: 10, after: "cur_con", search: "ada", subscribed: "false" }); + + const { url } = getCall(fetchMock); + expect(url).toContain("/api/v1/contacts?"); + expect(url).toContain("limit=10"); + expect(url).toContain("after=cur_con"); + expect(url).toContain("search=ada"); + expect(url).toContain("subscribed=false"); + }); + + test("listV1 resolves the cursor envelope itself, not just its rows", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([contactV1("con_1")], "cur_2")); + + const page = await client.contacts.listV1(); + + expect(page.has_more).toBe(true); + expect(page.next_cursor).toBe("cur_2"); + expect(page.data[0]?.email).toBe("con_1@example.com"); + }); + + test("listAllV1 walks every page and yields individual contacts", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([contactV1("con_1")], "cur_2")) + .mockResolvedValueOnce(cursorPage([contactV1("con_2"), contactV1("con_3")], null)); + + const seen: string[] = []; + for await (const contact of client.contacts.listAllV1({ search: "ada" })) seen.push(contact.id); + + expect(seen).toEqual(["con_1", "con_2", "con_3"]); + expect(fetchMock.mock.calls).toHaveLength(2); + // The filter is carried forward with the cursor — the cursor encodes it. + expect(getCall(fetchMock, 1).url).toContain("after=cur_2"); + expect(getCall(fetchMock, 1).url).toContain("search=ada"); + }); + + test("topicPreferences GETs the contact's topics sub-path", async () => { + const { client, fetchMock } = makeClient(); + const preferences = { + contact_id: "con_1", + subscribed: false, + topics: [{ topic_id: "top_1", key: "product-news", name: "Product news", subscribed: true, pending: false }], + }; + fetchMock.mockResolvedValue(jsonResponse(200, preferences)); + + const result = await client.contacts.topicPreferences("con_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/contacts/con_1/topics"); + expect(init.method).toBe("GET"); + // The global opt-out outranks the per-topic answers; both must survive the trip. + expect(result.subscribed).toBe(false); + expect(result.topics[0]?.key).toBe("product-news"); + }); + + test("contact ids are URL-encoded into every v1 path", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, contactV1("a/b"))); + await client.contacts.getV1("a/b"); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/contacts/a%2Fb"); + }); + + test("getV1 surfaces a 404 problem document as SendlyNotFoundError", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(404, { error: { message: "no such contact", code: "not_found" } })); + await expect(client.contacts.getV1("con_missing")).rejects.toBeInstanceOf(SendlyNotFoundError); + }); +}); diff --git a/src/__tests__/contract.test.ts b/src/__tests__/contract.test.ts index ba43edf..3c6e448 100644 --- a/src/__tests__/contract.test.ts +++ b/src/__tests__/contract.test.ts @@ -157,7 +157,13 @@ const MANIFEST: readonly ManifestEntry[] = [ { key: "templates.create", invoke: (c) => - c.templates.create({ name: "n", subject: "s", body: "b", from: "sender@example.com", type: "TRANSACTIONAL" }), + c.templates.create({ + name: "n", + subject: "s", + body: "b", + from: "sender@example.com", + emailCategory: "TRANSACTIONAL", + }), }, { key: "templates.list", invoke: (c) => c.templates.list() }, { key: "templates.get", invoke: (c) => c.templates.get(ID) }, @@ -237,6 +243,123 @@ const MANIFEST: readonly ManifestEntry[] = [ { key: "events.record", invoke: (c) => c.events.record({ name: "user.signup" }) }, { key: "events.listNames", invoke: (c) => c.events.listNames() }, { key: "events.stats", invoke: (c) => c.events.stats() }, + + // --- 1.1: the six resources that had a legacy half and no v1 one --- + // + // Both dialects stay reachable, so the v1 methods carry a `V1` suffix rather + // than taking the plain name. The two answer the same question with different + // envelopes, field cases and error bodies, and a call site that mixes them up + // reads a `data` that is not there. + // contacts (v1) + { key: "contacts.listV1", invoke: (c) => c.contacts.listV1() }, + { key: "contacts.listAllV1", invoke: (c) => c.contacts.listAllV1().next() }, + { key: "contacts.createV1", invoke: (c) => c.contacts.createV1({ email: "user@example.com" }) }, + { key: "contacts.getV1", invoke: (c) => c.contacts.getV1(ID) }, + { key: "contacts.updateV1", invoke: (c) => c.contacts.updateV1(ID, {}) }, + { key: "contacts.deleteV1", invoke: (c) => c.contacts.deleteV1(ID) }, + { key: "contacts.topicPreferences", invoke: (c) => c.contacts.topicPreferences(ID) }, + // lists (v1) + { key: "lists.listV1", invoke: (c) => c.lists.listV1() }, + { key: "lists.listAllV1", invoke: (c) => c.lists.listAllV1().next() }, + { key: "lists.createV1", invoke: (c) => c.lists.createV1({ name: "Weekly" }) }, + { key: "lists.getV1", invoke: (c) => c.lists.getV1(ID) }, + { key: "lists.updateV1", invoke: (c) => c.lists.updateV1(ID, {}) }, + { key: "lists.deleteV1", invoke: (c) => c.lists.deleteV1(ID) }, + { key: "lists.startValidationRun", invoke: (c) => c.lists.startValidationRun(ID) }, + // templates (v1) + { key: "templates.listV1", invoke: (c) => c.templates.listV1() }, + { key: "templates.listAllV1", invoke: (c) => c.templates.listAllV1().next() }, + { + key: "templates.createV1", + invoke: (c) => c.templates.createV1({ name: "n", subject: "s", body: "b", from: "sender@example.com" }), + }, + { key: "templates.getV1", invoke: (c) => c.templates.getV1(ID) }, + { key: "templates.updateV1", invoke: (c) => c.templates.updateV1(ID, {}) }, + { key: "templates.deleteV1", invoke: (c) => c.templates.deleteV1(ID) }, + // domains (v1, plus the one legacy write that was never wrapped) + { key: "domains.listV1", invoke: (c) => c.domains.listV1() }, + { key: "domains.listAllV1", invoke: (c) => c.domains.listAllV1().next() }, + { key: "domains.createV1", invoke: (c) => c.domains.createV1({ domain: "mail.example.com" }) }, + { key: "domains.getV1", invoke: (c) => c.domains.getV1(ID) }, + { key: "domains.verifyV1", invoke: (c) => c.domains.verifyV1(ID) }, + { key: "domains.deleteV1", invoke: (c) => c.domains.deleteV1(ID) }, + { key: "domains.assignStream", invoke: (c) => c.domains.assignStream(ID, {}) }, + // webhooks (v1) + { key: "webhooks.listV1", invoke: (c) => c.webhooks.listV1() }, + { key: "webhooks.listAllV1", invoke: (c) => c.webhooks.listAllV1().next() }, + { + key: "webhooks.createV1", + invoke: (c) => c.webhooks.createV1({ url: "https://example.com/hook", event_types: ["email.delivered"] }), + }, + { key: "webhooks.getV1", invoke: (c) => c.webhooks.getV1(ID) }, + { key: "webhooks.updateV1", invoke: (c) => c.webhooks.updateV1(ID, {}) }, + { key: "webhooks.deleteV1", invoke: (c) => c.webhooks.deleteV1(ID) }, + { key: "webhooks.rotateSecretV1", invoke: (c) => c.webhooks.rotateSecretV1(ID) }, + // suppressions (v1) — the path parameter is an address, not an id + { key: "suppression.listV1", invoke: (c) => c.suppression.listV1() }, + { key: "suppression.listAllV1", invoke: (c) => c.suppression.listAllV1().next() }, + { key: "suppression.createV1", invoke: (c) => c.suppression.createV1({ email: "user@example.com" }) }, + { key: "suppression.getV1", invoke: (c) => c.suppression.getV1(EMAIL) }, + { key: "suppression.deleteV1", invoke: (c) => c.suppression.deleteV1(EMAIL) }, + + // --- 1.1: resources that are new in their entirety --- + // topics — the consent vocabulary a project mails against + { key: "topics.list", invoke: (c) => c.topics.list() }, + { key: "topics.listAll", invoke: (c) => c.topics.listAll().next() }, + { key: "topics.create", invoke: (c) => c.topics.create({ key: "product-news", name: "Product news" }) }, + { key: "topics.get", invoke: (c) => c.topics.get(ID) }, + { key: "topics.update", invoke: (c) => c.topics.update(ID, {}) }, + { + key: "topics.setSubscription", + invoke: (c) => c.topics.setSubscription(ID, { contact_id: ID, subscribed: true }), + }, + // snippets — legacy dialect, gated by the template scopes + { key: "snippets.create", invoke: (c) => c.snippets.create({ name: "footer", body: "

bye

" }) }, + { key: "snippets.list", invoke: (c) => c.snippets.list() }, + { key: "snippets.get", invoke: (c) => c.snippets.get(ID) }, + { key: "snippets.update", invoke: (c) => c.snippets.update(ID, {}) }, + { key: "snippets.delete", invoke: (c) => c.snippets.delete(ID) }, + // validation — the one part of this SDK that spends money + { key: "validation.validateEmails", invoke: (c) => c.validation.validateEmails({ emails: ["user@example.com"] }) }, + { key: "validation.getRun", invoke: (c) => c.validation.getRun(ID) }, + { key: "validation.listResults", invoke: (c) => c.validation.listResults(ID) }, + { key: "validation.listResultsAll", invoke: (c) => c.validation.listResultsAll(ID).next() }, + // deliverability + { key: "deliverability.diagnose", invoke: (c) => c.deliverability.diagnose({ domain: "mail.example.com" }) }, + { key: "deliverability.listDomainStats", invoke: (c) => c.deliverability.listDomainStats() }, + { key: "deliverability.listDomainStatsAll", invoke: (c) => c.deliverability.listDomainStatsAll().next() }, + { key: "deliverability.listDmarcReports", invoke: (c) => c.deliverability.listDmarcReports() }, + { key: "deliverability.listDmarcReportsAll", invoke: (c) => c.deliverability.listDmarcReportsAll().next() }, + + // --- 1.1: operations added to resources that already existed --- + { key: "campaigns.listFailures", invoke: (c) => c.campaigns.listFailures(ID) }, + { key: "campaigns.listFailuresAll", invoke: (c) => c.campaigns.listFailuresAll(ID).next() }, + { key: "campaigns.retryFailed", invoke: (c) => c.campaigns.retryFailed(ID) }, + { key: "workflows.getGraph", invoke: (c) => c.workflows.getGraph(ID) }, + { + key: "workflows.replaceGraph", + invoke: (c) => + c.workflows.replaceGraph(ID, { + steps: [ + { + id: "11111111-1111-4111-8111-111111111111", + name: "Signed up", + position: { x: 0, y: 0 }, + type: "TRIGGER", + config: { eventName: "user.signup" }, + }, + ], + transitions: [], + }), + }, + { key: "workflows.clone", invoke: (c) => c.workflows.clone(ID, {}) }, + { key: "workflows.pause", invoke: (c) => c.workflows.pause(ID) }, + { key: "workflows.resume", invoke: (c) => c.workflows.resume(ID) }, + { + key: "mailboxes.sendMessage", + invoke: (c) => c.mailboxes.sendMessage(ID, { to: ["user@example.com"], subject: "s", body: "b" }), + }, + { key: "mailboxes.draftMessage", invoke: (c) => c.mailboxes.draftMessage(ID, { mode: "draft" }) }, ]; const RESOURCE_NAMES = [ @@ -256,6 +379,10 @@ const RESOURCE_NAMES = [ "analytics", "usage", "projects", + "topics", + "validation", + "deliverability", + "snippets", ] as const; // --------------------------------------------------------------------------- @@ -472,6 +599,12 @@ describe("OpenAPI contract", () => { test("every cursor-paginated v1 list method has a companion auto-pagination generator", () => { // The v1 list envelope is `{ data, has_more, next_cursor }`; any operation // answering with it should be walkable without the caller managing cursors. + // + // Two envelopes exist. Topics and validation results answer + // `{ data, cursor, has_more }` — the next page arrives under `cursor`, and the + // request parameter is `cursor` rather than `after`. Those resources drive the + // loop themselves instead of using the shared helper, so this guard looks for + // the generator, not for the helper. const cursorListOps = new Set(); for (const [path, methods] of Object.entries(spec.paths)) { if (!path.startsWith("/api/v1")) continue; @@ -479,7 +612,7 @@ describe("OpenAPI contract", () => { const name = schema?.$ref?.split("/").pop(); const resolved = name ? spec.components?.schemas?.[name] : undefined; const props = resolved?.properties ?? schema?.properties; - if (props && "data" in props && "has_more" in props && "next_cursor" in props) { + if (props && "data" in props && "has_more" in props && ("next_cursor" in props || "cursor" in props)) { cursorListOps.add(`GET ${path}`); } } @@ -488,7 +621,10 @@ describe("OpenAPI contract", () => { const missing = [...cursorListOps] .filter((op) => { const listMethods = [...sdkOps.entries()].filter(([, emitted]) => emitted === op).map(([key]) => key); - return !listMethods.some((key) => discovered.has(`${key}All`)); + // `list` -> `listAll`, and `listV1` -> `listAllV1`: on the suffixed pair the + // word `All` goes with the verb, not after the dialect marker, because + // `listV1All` reads as a third thing rather than as the walk of `listV1`. + return !listMethods.some((key) => discovered.has(`${key}All`) || discovered.has(key.replace(/V1$/, "AllV1"))); }) .sort(); expect(missing).toEqual([]); diff --git a/src/__tests__/deliverability.test.ts b/src/__tests__/deliverability.test.ts new file mode 100644 index 0000000..a8e6f4d --- /dev/null +++ b/src/__tests__/deliverability.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import type { DmarcReportV1, RecipientDomainStatsV1 } from "../types"; +import { cursorPage, getCall, jsonResponse, makeClient } from "./helpers"; + +function domainStats(domain: string, day: string): RecipientDomainStatsV1 { + return { + domain, + day, + sent: 100, + delivered: 92, + bounced: 7, + complained: 1, + opened: 40, + computed_at: "2026-09-01T00:00:00.000Z", + }; +} + +function dmarcReport(id: string): DmarcReportV1 { + return { + id, + report_id: `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", + }; +} + +describe("deliverability resource (/api/v1)", () => { + test("diagnose GETs /deliverability/diagnose with every query parameter serialized", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { domain: "example.com", address: null, findings: [] })); + + await client.deliverability.diagnose({ + domain: "example.com", + address: "person@gmail.com", + window_days: 14, + }); + + const { url, init } = getCall(fetchMock); + expect(init.method).toBe("GET"); + expect(url).toContain("http://localhost/api/v1/deliverability/diagnose?"); + expect(url).toContain("domain=example.com"); + expect(url).toContain("address=person%40gmail.com"); + expect(url).toContain("window_days=14"); + }); + + test("diagnose resolves the diagnosis body as sent, findings and all", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + domain: "example.com", + address: null, + checked_at: "2026-09-01T00:00:00.000Z", + identity: { registered: true, verified: false, dkim_status: "FAILED" }, + suppression: null, + recent_delivery: { window_days: 7, scope: "project", sent: 500, bounced: 40 }, + findings: [{ code: "dkim_failed", severity: "critical", summary: "DKIM is failing.", remedy: "Re-add DNS." }], + }), + ); + + const diagnosis = await client.deliverability.diagnose({ domain: "example.com" }); + + expect(diagnosis.findings[0]?.code).toBe("dkim_failed"); + expect(diagnosis.recent_delivery.scope).toBe("project"); + }); + + test("listDomainStats GETs the recipient-domain rollup with its filters", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([domainStats("gmail.com", "2026-09-01")], null)); + + const page = await client.deliverability.listDomainStats({ limit: 50, days: 7, domain: "gmail.com" }); + + const { url, init } = getCall(fetchMock); + expect(init.method).toBe("GET"); + expect(url).toContain("http://localhost/api/v1/deliverability/domains?"); + expect(url).toContain("limit=50"); + expect(url).toContain("days=7"); + expect(url).toContain("domain=gmail.com"); + // Not unwrapped: the caller gets the envelope, not its `data` array. + expect(page.has_more).toBe(false); + expect(page.data[0]?.domain).toBe("gmail.com"); + }); + + test("listDomainStatsAll walks two pages on `after` and stops", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([domainStats("gmail.com", "2026-09-02")], "cur_2")) + .mockResolvedValueOnce(cursorPage([domainStats("outlook.com", "2026-09-02")], null)); + + const seen: string[] = []; + for await (const row of client.deliverability.listDomainStatsAll({ days: 2 })) seen.push(row.domain); + + expect(seen).toEqual(["gmail.com", "outlook.com"]); + expect(fetchMock.mock.calls).toHaveLength(2); + const second = getCall(fetchMock, 1).url; + expect(second).toContain("after=cur_2"); + expect(second).not.toContain("cursor=cur_2"); + expect(second).toContain("days=2"); + }); + + test("listDmarcReports GETs /deliverability/dmarc and returns the envelope", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([dmarcReport("dmr_1")], null)); + + const page = await client.deliverability.listDmarcReports({ limit: 10, days: 90, domain: "example.com" }); + + const { url, init } = getCall(fetchMock); + expect(init.method).toBe("GET"); + expect(url).toContain("http://localhost/api/v1/deliverability/dmarc?"); + expect(url).toContain("limit=10"); + expect(url).toContain("days=90"); + expect(url).toContain("domain=example.com"); + expect(page.next_cursor).toBeNull(); + expect(page.data[0]?.policy_domain).toBe("example.com"); + }); + + test("an empty DMARC page is a well-formed answer, not an error", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([], null)); + + const page = await client.deliverability.listDmarcReports(); + + expect(page.data).toEqual([]); + expect(page.has_more).toBe(false); + }); + + test("listDmarcReportsAll walks two pages on `after` and stops", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([dmarcReport("dmr_1")], "cur_2")) + .mockResolvedValueOnce(cursorPage([dmarcReport("dmr_2")], null)); + + const seen: string[] = []; + for await (const report of client.deliverability.listDmarcReportsAll()) seen.push(report.id); + + expect(seen).toEqual(["dmr_1", "dmr_2"]); + expect(fetchMock.mock.calls).toHaveLength(2); + expect(getCall(fetchMock, 1).url).toContain("after=cur_2"); + expect(getCall(fetchMock, 1).url).not.toContain("cursor="); + }); +}); diff --git a/src/__tests__/domains.test.ts b/src/__tests__/domains.test.ts index 99b1fdd..cdd9bef 100644 --- a/src/__tests__/domains.test.ts +++ b/src/__tests__/domains.test.ts @@ -1,6 +1,18 @@ import { describe, expect, test } from "vitest"; -import { SendlyPermissionError } from "../index"; -import { getCall, jsonResponse, makeClient } from "./helpers"; +import { SendlyConflictError, SendlyPermissionError } from "../index"; +import type { DomainV1 } from "../types"; +import { cursorPage, getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers"; + +function domainV1(id: string): DomainV1 { + // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter + return { + id, + domain: `${id}.example.com`, + verified: true, + dkim_verified: false, + mail_from_domain_status: "Success", + } as unknown as DomainV1; +} describe("domains setup hand-off", () => { test("startSetup POSTs the dodomain-session route and returns the link verbatim", async () => { @@ -68,3 +80,143 @@ describe("domains resource", () => { await expect(client.domains.create({ domain: "x.com" })).rejects.toBeInstanceOf(SendlyPermissionError); }); }); + +describe("domains stream assignment (legacy)", () => { + test("assignStream PATCHes /api/domains/{id} with the camelCase body", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { id: "d_1", name: "mail.example.com", stream: "MARKETING", streamDefault: true }, + }), + ); + + await client.domains.assignStream("d_1", { + stream: "MARKETING", + streamDefault: true, + defaultFromAddress: "news@mail.example.com", + }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/domains/d_1"); + expect(init.method).toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ + stream: "MARKETING", + streamDefault: true, + defaultFromAddress: "news@mail.example.com", + }); + }); + + test("assignStream unwraps the legacy envelope rather than resolving it whole", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { success: true, data: { id: "d_1", stream: null, streamDefault: false } }), + ); + + const domain = await client.domains.assignStream("d_1", { stream: null }); + + // The `{ success, data }` wrapper is peeled off — the record itself resolves. + expect(domain).toEqual({ id: "d_1", stream: null, streamDefault: false }); + expect(domain).not.toHaveProperty("success"); + }); +}); + +describe("domains resource (/api/v1)", () => { + test("createV1 POSTs /api/v1/domains and resolves the bare domain", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(201, { id: "dom_1", domain: "mail.example.com", verified: false })); + + const created = await client.domains.createV1({ domain: "mail.example.com", region: "eu-west-1" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/domains"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ domain: "mail.example.com", region: "eu-west-1" }); + // Nothing is verified until the DKIM records resolve. + expect(created.verified).toBe(false); + }); + + test("v1 responses are bare bodies — no envelope is unwrapped", async () => { + const { client, fetchMock } = makeClient(); + const body = { id: "dom_1", domain: "mail.example.com", verified: true, dkim_verified: false }; + fetchMock.mockResolvedValue(jsonResponse(200, body)); + + // A v1 body has no `data` key to unwrap, so unwrapping would lose the whole record. + expect(await client.domains.getV1("dom_1")).toEqual(body); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/domains/dom_1"); + }); + + test("verifyV1 POSTs the v1 verify sub-path and answers the refreshed domain", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { id: "dom_1", domain: "mail.example.com", verified: true, dkim_verified: true }), + ); + + const refreshed = await client.domains.verifyV1("dom_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/domains/dom_1/verify"); + expect(init.method).toBe("POST"); + // It reports what SES now sees; it does not edit the domain's own fields. + expect(init.body).toBeUndefined(); + expect(refreshed.verified).toBe(true); + expect(refreshed.dkim_verified).toBe(true); + }); + + test("deleteV1 DELETEs the v1 path and resolves the deletion receipt", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "dom_1", deleted: true })); + + const deleted = await client.domains.deleteV1("dom_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/domains/dom_1"); + expect(init.method).toBe("DELETE"); + expect(deleted).toEqual({ id: "dom_1", deleted: true }); + }); + + test("listV1 serializes cursor query params", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([], null)); + + await client.domains.listV1({ limit: 10, after: "cur_dom" }); + + const { url } = getCall(fetchMock); + expect(url).toContain("http://localhost/api/v1/domains?"); + expect(url).toContain("limit=10"); + expect(url).toContain("after=cur_dom"); + }); + + test("listAllV1 walks every page and yields individual domains", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([domainV1("dom_1")], "cur_2")) + .mockResolvedValueOnce(cursorPage([domainV1("dom_2"), domainV1("dom_3")], null)); + + const seen: string[] = []; + for await (const item of client.domains.listAllV1({ limit: 1 })) seen.push(item.id); + + expect(seen).toEqual(["dom_1", "dom_2", "dom_3"]); + expect(fetchMock.mock.calls).toHaveLength(2); + expect(getCall(fetchMock, 1).url).toContain("after=cur_2"); + expect(getCall(fetchMock, 1).url).toContain("limit=1"); + }); + + test("deleting a domain still sending mail surfaces the conflict code", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + problemResponse(409, { + type: "https://docs.sendly.now/errors/conflict", + title: "Conflict", + detail: "Domain is still used by 1 active campaign.", + code: "conflict", + request_id: "req_dom_conflict", + }), + ); + + const error = await rejection(client.domains.deleteV1("dom_1")); + expect(error).toBeInstanceOf(SendlyConflictError); + expect(error.errorCode).toBe("conflict"); + expect(error.requestId).toBe("req_dom_conflict"); + }); +}); diff --git a/src/__tests__/events.test.ts b/src/__tests__/events.test.ts index 655207e..63a7bca 100644 --- a/src/__tests__/events.test.ts +++ b/src/__tests__/events.test.ts @@ -46,13 +46,15 @@ describe("events resource (/api/v1)", () => { const { client, fetchMock } = makeClient(); fetchMock.mockResolvedValue(jsonResponse(201, { id: "ev_1", name: "user.signup", contact_id: "ct_1" })); - // `data` takes arbitrary JSON — scalars included, not only nested objects. - const recorded = await client.events.record({ name: "user.signup", contact_id: "ct_1", data: { plan: "pro" } }); + // `payload` takes arbitrary JSON — scalars included, not only nested objects. + // It was `data` before 1.1; the field is the event's own body, and `data` said + // nothing about whose it was on a wire where every envelope has a `data`. + const recorded = await client.events.record({ name: "user.signup", contact_id: "ct_1", payload: { plan: "pro" } }); const { url, init } = getCall(fetchMock); expect(url).toBe("http://localhost/api/v1/events"); expect(init.method).toBe("POST"); - expect(getCallBody(fetchMock)).toEqual({ name: "user.signup", contact_id: "ct_1", data: { plan: "pro" } }); + expect(getCallBody(fetchMock)).toEqual({ name: "user.signup", contact_id: "ct_1", payload: { plan: "pro" } }); // No envelope on v1 — the created event is the response body. expect(recorded.id).toBe("ev_1"); }); diff --git a/src/__tests__/lists.test.ts b/src/__tests__/lists.test.ts index c150b5d..f44410d 100644 --- a/src/__tests__/lists.test.ts +++ b/src/__tests__/lists.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; import { SendlyConflictError } from "../index"; -import { getCall, getCallBody, jsonResponse, makeClient, rejection } from "./helpers"; +import type { ListV1 } from "../types"; +import { cursorPage, getCall, getCallBody, jsonResponse, makeClient, rejection } from "./helpers"; + +function listV1(id: string): ListV1 { + // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter + return { id, name: `List ${id}`, double_opt_in: false, member_count: 3 } as unknown as ListV1; +} describe("lists resource (legacy /api)", () => { test("subscribe POSTs /api/lists/{id}/subscribe and unwraps the envelope to data", async () => { @@ -44,7 +50,7 @@ describe("lists resource (legacy /api)", () => { const result = await client.lists.subscribe("lst_1", { email: "user@example.com" }); // Sendly does not send the confirmation email — the caller delivers - // /api/lists/confirm?token= to the contact. + // /api/lists/confirm-subscription?token= to the contact. expect(result.status).toBe("PENDING"); expect(result.confirmToken).toBe("tok_abc"); }); @@ -90,3 +96,142 @@ describe("lists resource (legacy /api)", () => { expect(error.errorCode).toBe("RESUBSCRIBE_CONFIRMATION_REQUIRED"); }); }); + +describe("lists resource (/api/v1)", () => { + test("createV1 POSTs /api/v1/lists and resolves the bare body, unwrapping nothing", async () => { + const { client, fetchMock } = makeClient(); + const body = { + id: "lst_1", + name: "Weekly digest", + description: null, + double_opt_in: true, + confirmation_template_id: null, + redirect_url: null, + member_count: 0, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }; + fetchMock.mockResolvedValue(jsonResponse(201, body)); + + const created = await client.lists.createV1({ name: "Weekly digest", double_opt_in: true }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/lists"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ name: "Weekly digest", double_opt_in: true }); + // v1 answers a bare body: the whole document reaches the caller. + expect(created).toEqual(body); + }); + + test("getV1, updateV1 and deleteV1 build the right verb and path", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "lst_1", deleted: true })); + + await client.lists.getV1("lst_1"); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/lists/lst_1"); + expect(getCall(fetchMock).init.method).toBe("GET"); + + fetchMock.mockClear(); + await client.lists.updateV1("lst_1", { name: "Renamed", redirect_url: null }); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/lists/lst_1"); + expect(getCall(fetchMock).init.method).toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ name: "Renamed", redirect_url: null }); + + fetchMock.mockClear(); + const deleted = await client.lists.deleteV1("lst_1"); + expect(getCall(fetchMock).init.method).toBe("DELETE"); + expect(deleted).toEqual({ id: "lst_1", deleted: true }); + }); + + test("listV1 serializes cursor query params", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([], null)); + + await client.lists.listV1({ limit: 25, after: "cur_lst" }); + + const { url, init } = getCall(fetchMock); + expect(url).toContain("/api/v1/lists?"); + expect(url).toContain("limit=25"); + expect(url).toContain("after=cur_lst"); + expect(init.method).toBe("GET"); + }); + + test("listV1 resolves the cursor envelope itself, not just its rows", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([listV1("lst_1")], "cur_2")); + + const page = await client.lists.listV1(); + + expect(page.has_more).toBe(true); + expect(page.next_cursor).toBe("cur_2"); + expect(page.data[0]?.member_count).toBe(3); + }); + + test("listAllV1 walks every page and yields individual lists", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([listV1("lst_1")], "cur_2")) + .mockResolvedValueOnce(cursorPage([listV1("lst_2"), listV1("lst_3")], null)); + + const seen: string[] = []; + for await (const item of client.lists.listAllV1({ limit: 1 })) seen.push(item.id); + + expect(seen).toEqual(["lst_1", "lst_2", "lst_3"]); + expect(fetchMock.mock.calls).toHaveLength(2); + expect(getCall(fetchMock, 1).url).toContain("after=cur_2"); + expect(getCall(fetchMock, 1).url).toContain("limit=1"); + }); + + test("startValidationRun POSTs the list's validation-runs sub-path and resolves the pending run", async () => { + const { client, fetchMock } = makeClient(); + const run = { + id: "vrun_1", + list_id: "lst_1", + status: "pending", + processed_count: 0, + deliverable_count: 0, + undeliverable_count: 0, + risky_count: 0, + started_at: null, + completed_at: null, + failure_reason: null, + created_at: "2026-01-01T00:00:00.000Z", + }; + fetchMock.mockResolvedValue(jsonResponse(202, run)); + + const started = await client.lists.startValidationRun("lst_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/lists/lst_1/validation-runs"); + expect(init.method).toBe("POST"); + // Billed per address checked, so the caller must be able to see the run it just paid to start. + expect(started).toEqual(run); + }); + + test("list ids are URL-encoded into every v1 path", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, listV1("a/b"))); + + await client.lists.getV1("a/b"); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/lists/a%2Fb"); + + fetchMock.mockClear(); + fetchMock.mockResolvedValue(jsonResponse(202, { id: "vrun_1" })); + await client.lists.startValidationRun("a/b"); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/lists/a%2Fb/validation-runs"); + }); + + test("deleting a list still referenced elsewhere surfaces the problem document's code", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(409, { + success: false, + error: { message: "List is referenced by 1 campaign.", code: "conflict" }, + }), + ); + + const error = await rejection(client.lists.deleteV1("lst_1")); + expect(error).toBeInstanceOf(SendlyConflictError); + expect(error.errorCode).toBe("conflict"); + }); +}); diff --git a/src/__tests__/mailboxes.test.ts b/src/__tests__/mailboxes.test.ts index 81a9a9e..e966400 100644 --- a/src/__tests__/mailboxes.test.ts +++ b/src/__tests__/mailboxes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { SendlyNotFoundError } from "../index"; -import { getCall, jsonResponse, makeClient, rejection } from "./helpers"; +import { getCall, getCallBody, jsonResponse, makeClient, rejection } from "./helpers"; const MAILBOX = { id: "mb_1", @@ -87,4 +87,99 @@ describe("mailboxes resource", () => { const error = await rejection(client.mailboxes.get("nope")); expect(error).toBeInstanceOf(SendlyNotFoundError); }); + + test("sendMessage POSTs the composed body to /messages and unwraps the legacy envelope", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(201, { + success: true, + data: { submitted: true, conversationId: "cv_1", messageId: "msg_1" }, + }), + ); + + const submitted = await client.mailboxes.sendMessage("mb_1", { + to: ["customer@example.com"], + subject: "Your order", + body: "It shipped this morning.", + }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/mailboxes/mb_1/messages"); + expect(init.method).toBe("POST"); + // Unlike the v1 resources, this legacy route's `{ success, data }` wrapper is stripped. + expect(submitted).toEqual({ submitted: true, conversationId: "cv_1", messageId: "msg_1" }); + }); + + test("sendMessage takes no `from` — the mailbox in the path is the sender", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(201, { + success: true, + data: { submitted: true, conversationId: "cv_1", messageId: "msg_1" }, + }), + ); + + await client.mailboxes.sendMessage("mb_1", { + to: ["customer@example.com"], + bcc: ["archive@example.com"], + subject: "Your order", + body: "It shipped this morning.", + }); + + const body = getCallBody(fetchMock) as Record; + expect(body).toEqual({ + to: ["customer@example.com"], + bcc: ["archive@example.com"], + subject: "Your order", + body: "It shipped this morning.", + }); + expect(Object.keys(body)).not.toContain("from"); + }); + + test("draftMessage POSTs to /drafts, unwraps, and comes back with sent: false", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { subject: "Your order shipped", body: "Hi there —", subjects: [], sent: false }, + }), + ); + + const draft = await client.mailboxes.draftMessage("mb_1", { mode: "draft", brief: "order shipped" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/mailboxes/mb_1/drafts"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ mode: "draft", brief: "order shipped" }); + // The whole safety story of this pair: drafting never mails anybody. + expect(draft.sent).toBe(false); + expect(draft.subject).toBe("Your order shipped"); + }); + + test("draftMessage in subject mode returns the alternatives, not a send receipt", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { subject: null, body: null, subjects: ["Shipped!", "On its way"], sent: false }, + }), + ); + + const draft = await client.mailboxes.draftMessage("mb_1", { mode: "subject", draft: "your order shipped" }); + + expect(draft.subjects).toEqual(["Shipped!", "On its way"]); + // A draft carries no conversation or message id — nothing was created. + expect(Object.keys(draft)).not.toContain("messageId"); + }); + + test("the composition routes percent-encode the mailbox id too", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { success: true, data: { subject: null, body: null, subjects: [], sent: false } }), + ); + + await client.mailboxes.draftMessage("mb/../evil", { mode: "draft" }); + + expect(getCall(fetchMock).url).toBe("http://localhost/api/mailboxes/mb%2F..%2Fevil/drafts"); + }); }); diff --git a/src/__tests__/snippets.test.ts b/src/__tests__/snippets.test.ts new file mode 100644 index 0000000..be8d10f --- /dev/null +++ b/src/__tests__/snippets.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "vitest"; +import { SendlyConflictError } from "../index"; +import { getCall, getCallBody, jsonResponse, makeClient } from "./helpers"; + +const SNIPPET = { + id: "snp_1", + projectId: "prj_1", + name: "footer", + description: null, + body: "

Unsubscribe

", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("snippets resource", () => { + test("create POSTs /api/snippets and unwraps the envelope to the record", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(201, { success: true, data: SNIPPET })); + + const created = await client.snippets.create({ name: "footer", body: "

Unsubscribe

" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/snippets"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ name: "footer", body: "

Unsubscribe

" }); + // Legacy dialect: the caller gets the record, never the `{ success, data }` wrapper. + expect(created).toEqual(SNIPPET); + }); + + test("list serializes limit, cursor and search", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { data: [], total: 0, hasMore: false } })); + + await client.snippets.list({ limit: 25, cursor: "snp_50", search: "footer" }); + + const { url, init } = getCall(fetchMock); + expect(url).toContain("http://localhost/api/snippets?"); + expect(url).toContain("limit=25"); + expect(url).toContain("cursor=snp_50"); + expect(url).toContain("search=footer"); + expect(init.method).toBe("GET"); + }); + + test("list keeps the envelope — the legacy list response is `{ success, data: { data, total, hasMore } }`", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { success: true, data: { data: [SNIPPET], total: 1, cursor: "snp_1", hasMore: true } }), + ); + + const response = await client.snippets.list(); + + expect(response.success).toBe(true); + expect(response.data.total).toBe(1); + expect(response.data.hasMore).toBe(true); + expect(response.data.data[0]?.name).toBe("footer"); + }); + + test("get unwraps to the record at the id path", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: SNIPPET })); + + const snippet = await client.snippets.get("snp_1"); + + expect(getCall(fetchMock).url).toBe("http://localhost/api/snippets/snp_1"); + expect(getCall(fetchMock).init.method).toBe("GET"); + expect(snippet.body).toBe("

Unsubscribe

"); + }); + + test("update PATCHes and unwraps", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { ...SNIPPET, name: "footer_v2" } })); + + const updated = await client.snippets.update("snp_1", { name: "footer_v2" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/snippets/snp_1"); + expect(init.method).toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ name: "footer_v2" }); + expect(updated.name).toBe("footer_v2"); + }); + + test("delete sends DELETE and resolves void on 200 { success, data: { id } }", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { id: "snp_1" } })); + + await expect(client.snippets.delete("snp_1")).resolves.toBeUndefined(); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/snippets/snp_1"); + expect(init.method).toBe("DELETE"); + }); + + test("create throws SendlyConflictError when the name is already taken", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(409, { error: { message: "snippet name already exists", code: "conflict" } }), + ); + + await expect(client.snippets.create({ name: "footer", body: "x" })).rejects.toBeInstanceOf(SendlyConflictError); + }); +}); diff --git a/src/__tests__/suppression.test.ts b/src/__tests__/suppression.test.ts index 7e679fc..4aa69df 100644 --- a/src/__tests__/suppression.test.ts +++ b/src/__tests__/suppression.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "vitest"; -import { SendlyServerError } from "../index"; -import { emptyResponse, getCall, jsonResponse, makeClient } from "./helpers"; +import { SendlyNotFoundError, SendlyServerError } from "../index"; +import type { SuppressionV1 } from "../types"; +import { + cursorPage, + emptyResponse, + getCall, + getCallBody, + jsonResponse, + makeClient, + problemResponse, + rejection, +} from "./helpers"; + +function suppressionV1(email: string): SuppressionV1 { + // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter + return { email, reason: "MANUAL", source: "API", created_at: "2026-01-01T00:00:00.000Z" } as unknown as SuppressionV1; +} describe("suppression resource", () => { test("add POSTs /api/suppression", async () => { @@ -41,3 +56,111 @@ describe("suppression resource", () => { ); }); }); + +describe("suppression resource (/api/v1)", () => { + test("createV1 POSTs the plural /api/v1/suppressions path and resolves the bare record", async () => { + const { client, fetchMock } = makeClient(); + const created = suppressionV1("spam@x.com"); + fetchMock.mockResolvedValue(jsonResponse(201, created)); + + const result = await client.suppression.createV1({ email: "spam@x.com", reason: "COMPLAINT" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/suppressions"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ email: "spam@x.com", reason: "COMPLAINT" }); + // v1 answers a bare body — nothing is unwrapped out of a `{ success, data }` envelope. + expect(result).toEqual(created); + }); + + test("getV1 percent-encodes the address, so a `+` stays part of the local part", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, suppressionV1("user+tag@example.com"))); + + await client.suppression.getV1("user+tag@example.com"); + + // `+` must survive as %2B; an encoder that leaves it raw addresses a space instead. + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/suppressions/user%2Btag%40example.com"); + expect(getCall(fetchMock).init.method).toBe("GET"); + }); + + test("getV1 hands back the whole bare body rather than an envelope's `data`", async () => { + const { client, fetchMock } = makeClient(); + const body = suppressionV1("spam@x.com"); + fetchMock.mockResolvedValue(jsonResponse(200, body)); + + const result = await client.suppression.getV1("spam@x.com"); + + expect(result).toEqual(body); + expect(result.reason).toBe("MANUAL"); + }); + + test("getV1 on an address that is not suppressed rejects with 404, the definite negative answer", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + problemResponse(404, { + type: "https://docs.sendly.now/errors/resource_not_found", + title: "Not Found", + detail: "No suppression record for clean@example.com.", + code: "resource_not_found", + request_id: "req_sup_404", + }), + ); + + const error = await rejection(client.suppression.getV1("clean@example.com")); + expect(error).toBeInstanceOf(SendlyNotFoundError); + expect(error.requestId).toBe("req_sup_404"); + }); + + test("deleteV1 encodes the address and resolves the { email, deleted } acknowledgement", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { email: "user+tag@example.com", deleted: true })); + + const deleted = await client.suppression.deleteV1("user+tag@example.com"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/suppressions/user%2Btag%40example.com"); + expect(init.method).toBe("DELETE"); + expect(deleted).toEqual({ email: "user+tag@example.com", deleted: true }); + }); + + test("listV1 serializes the cursor params and the reason filter", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([], null)); + + await client.suppression.listV1({ limit: 5, after: "cur_sup", reason: "HARD_BOUNCE" }); + + const { url, init } = getCall(fetchMock); + expect(url).toContain("http://localhost/api/v1/suppressions?"); + expect(init.method).toBe("GET"); + expect(url).toContain("limit=5"); + expect(url).toContain("after=cur_sup"); + expect(url).toContain("reason=HARD_BOUNCE"); + }); + + test("listV1 returns the cursor envelope untouched", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([suppressionV1("spam@x.com")], "cur_2")); + + const page = await client.suppression.listV1(); + + expect(page.has_more).toBe(true); + expect(page.next_cursor).toBe("cur_2"); + expect(page.data).toEqual([suppressionV1("spam@x.com")]); + }); + + test("listAllV1 walks both pages, forwards the cursor, and stops on the last one", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([suppressionV1("one@x.com")], "cur_2")) + .mockResolvedValueOnce(cursorPage([suppressionV1("two@x.com"), suppressionV1("three@x.com")], null)); + + const seen: string[] = []; + for await (const item of client.suppression.listAllV1({ reason: "COMPLAINT" })) seen.push(item.email); + + expect(seen).toEqual(["one@x.com", "two@x.com", "three@x.com"]); + expect(fetchMock.mock.calls).toHaveLength(2); + expect(getCall(fetchMock, 1).url).toContain("after=cur_2"); + expect(getCall(fetchMock, 1).url).toContain("reason=COMPLAINT"); + }); +}); diff --git a/src/__tests__/templates.test.ts b/src/__tests__/templates.test.ts index 3fa08b3..6953ba7 100644 --- a/src/__tests__/templates.test.ts +++ b/src/__tests__/templates.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; import { SendlyConflictError } from "../index"; -import { getCall, jsonResponse, makeClient } from "./helpers"; +import type { TemplateV1 } from "../types"; +import { cursorPage, getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers"; + +function templateV1(id: string): TemplateV1 { + // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter + return { id, name: `Template ${id}`, email_category: "MARKETING", version: 1 } as unknown as TemplateV1; +} describe("templates resource", () => { test("create POSTs /api/templates", async () => { @@ -11,9 +17,12 @@ describe("templates resource", () => { 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, and the column it maps to was never called `type`. + emailCategory: "MARKETING", }); expect(getCall(fetchMock).url).toBe("http://localhost/api/templates"); + expect(getCallBody(fetchMock)).toMatchObject({ emailCategory: "MARKETING" }); }); test("list serializes limit + cursor", async () => { @@ -25,6 +34,15 @@ describe("templates resource", () => { expect(url).toContain("cursor=c_abc"); }); + test("list filters on emailCategory, the 1.1 name for the old `type` param", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { items: [] } })); + await client.templates.list({ emailCategory: "SELF_MANAGED_UNSUBSCRIBE" }); + const { url } = getCall(fetchMock); + expect(url).toContain("emailCategory=SELF_MANAGED_UNSUBSCRIBE"); + expect(url).not.toContain("type="); + }); + test("update PATCHes", async () => { const { client, fetchMock } = makeClient(); fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { id: "t_1" } })); @@ -45,3 +63,126 @@ describe("templates resource", () => { await expect(client.templates.delete("t_1")).rejects.toBeInstanceOf(SendlyConflictError); }); }); + +describe("templates resource (/api/v1)", () => { + test("createV1 POSTs /api/v1/templates and resolves the bare template", async () => { + const { client, fetchMock } = makeClient(); + const created = templateV1("tpl_1"); + fetchMock.mockResolvedValue(jsonResponse(201, created)); + + const result = await client.templates.createV1({ + name: "Welcome", + subject: "Welcome", + body: "

hi

", + from: "a@b.com", + email_category: "TRANSACTIONAL", + }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/templates"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ + name: "Welcome", + subject: "Welcome", + body: "

hi

", + from: "a@b.com", + email_category: "TRANSACTIONAL", + }); + // v1 answers a bare body — nothing is unwrapped out of a `{ success, data }` envelope. + expect(result).toEqual(created); + }); + + test("getV1 hands back the whole bare body rather than an envelope's `data`", async () => { + const { client, fetchMock } = makeClient(); + const body = templateV1("tpl_1"); + fetchMock.mockResolvedValue(jsonResponse(200, body)); + + const result = await client.templates.getV1("tpl_1"); + + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/templates/tpl_1"); + expect(getCall(fetchMock).init.method).toBe("GET"); + expect(result).toEqual(body); + expect(result.email_category).toBe("MARKETING"); + }); + + test("updateV1 PATCHes the id path with only the fields sent", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, templateV1("tpl_1"))); + + await client.templates.updateV1("tpl_1", { email_category: "SELF_MANAGED_UNSUBSCRIBE" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/templates/tpl_1"); + expect(init.method).toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ email_category: "SELF_MANAGED_UNSUBSCRIBE" }); + }); + + test("deleteV1 resolves the { id, deleted } acknowledgement the legacy delete discards", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "tpl_1", deleted: true })); + + const deleted = await client.templates.deleteV1("tpl_1"); + + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/templates/tpl_1"); + expect(getCall(fetchMock).init.method).toBe("DELETE"); + expect(deleted).toEqual({ id: "tpl_1", deleted: true }); + }); + + test("deleteV1 surfaces the RFC 9457 conflict raised by a template still in use", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + problemResponse(409, { + type: "https://docs.sendly.now/errors/conflict", + title: "Conflict", + detail: "Template is referenced by 1 scheduled campaign.", + code: "conflict", + request_id: "req_tpl_conflict", + }), + ); + + const error = await rejection(client.templates.deleteV1("tpl_1")); + expect(error).toBeInstanceOf(SendlyConflictError); + expect(error.requestId).toBe("req_tpl_conflict"); + }); + + test("listV1 serializes the cursor params and the snake_case category filter", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([], null)); + + await client.templates.listV1({ limit: 5, after: "cur_tpl", search: "welcome", email_category: "MARKETING" }); + + const { url, init } = getCall(fetchMock); + expect(url).toContain("http://localhost/api/v1/templates?"); + expect(init.method).toBe("GET"); + expect(url).toContain("limit=5"); + expect(url).toContain("after=cur_tpl"); + expect(url).toContain("search=welcome"); + expect(url).toContain("email_category=MARKETING"); + }); + + test("listV1 returns the cursor envelope untouched", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([templateV1("tpl_1")], "cur_2")); + + const page = await client.templates.listV1(); + + expect(page.next_cursor).toBe("cur_2"); + expect(page.has_more).toBe(true); + expect(page.data).toEqual([templateV1("tpl_1")]); + }); + + test("listAllV1 walks both pages, forwards the cursor, and stops on the last one", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([templateV1("tpl_1")], "cur_2")) + .mockResolvedValueOnce(cursorPage([templateV1("tpl_2"), templateV1("tpl_3")], null)); + + const seen: string[] = []; + for await (const item of client.templates.listAllV1({ email_category: "MARKETING" })) seen.push(item.id); + + expect(seen).toEqual(["tpl_1", "tpl_2", "tpl_3"]); + expect(fetchMock.mock.calls).toHaveLength(2); + expect(getCall(fetchMock, 1).url).toContain("after=cur_2"); + expect(getCall(fetchMock, 1).url).toContain("email_category=MARKETING"); + }); +}); diff --git a/src/__tests__/topics.test.ts b/src/__tests__/topics.test.ts new file mode 100644 index 0000000..b537b9c --- /dev/null +++ b/src/__tests__/topics.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "vitest"; +import type { TopicV1 } from "../types"; +import { getCall, getCallBody, jsonResponse, makeClient } from "./helpers"; + +function topic(id: string): TopicV1 { + // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter + return { id, key: id, name: `Topic ${id}`, archived: false } as unknown as TopicV1; +} + +/** + * One page of the topics list envelope. + * + * Deliberately not `helpers.cursorPage`: that builds the `next_cursor` field + * the rest of v1 answers with, and topics answer `cursor` instead. + */ +function topicPage(data: TopicV1[], cursor: string | null): Response { + return jsonResponse(200, { data, has_more: cursor !== null, cursor }); +} + +describe("topics resource (/api/v1)", () => { + test("list GETs /api/v1/topics and resolves the bare page, envelope and all", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(topicPage([topic("top_1")], null)); + + const page = await client.topics.list(); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/topics"); + expect(init.method).toBe("GET"); + // v1 does not wrap, so the page itself is returned — not its `data` array. + expect(page.has_more).toBe(false); + expect(page.data[0]?.id).toBe("top_1"); + }); + + test("list serializes limit, cursor and include_archived", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(topicPage([], null)); + + await client.topics.list({ limit: 10, cursor: "cur_top", include_archived: true }); + + const { url } = getCall(fetchMock); + expect(url).toContain("limit=10"); + expect(url).toContain("cursor=cur_top"); + expect(url).toContain("include_archived=true"); + // The v1 pagination parameter everywhere else; topics must not emit it. + expect(url).not.toContain("after="); + }); + + test("create POSTs /api/v1/topics with the key and opt-in default", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(201, { id: "top_1", key: "product_news", default_opt_in: false })); + + const created = await client.topics.create({ key: "product_news", name: "Product news", default_opt_in: false }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/topics"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ key: "product_news", name: "Product news", default_opt_in: false }); + expect(created.id).toBe("top_1"); + }); + + test("get and update build the right verb and path", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "top_1", key: "product_news" })); + + await client.topics.get("top_1"); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/topics/top_1"); + expect(getCall(fetchMock).init.method).toBe("GET"); + + fetchMock.mockClear(); + // Archiving is the retire path — there is no DELETE to test. + await client.topics.update("top_1", { archived: true }); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/topics/top_1"); + expect(getCall(fetchMock).init.method).toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ archived: true }); + }); + + test("setSubscription POSTs the contact to the topic's subscriptions sub-path", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + topic_id: "top_1", + contact_id: "con_1", + status: "pending", + confirmed_at: null, + confirmation_url: "https://sendly.now/c/tok_1", + }), + ); + + const subscription = await client.topics.setSubscription("top_1", { contact_id: "con_1", subscribed: true }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/topics/top_1/subscriptions"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ contact_id: "con_1", subscribed: true }); + // Asking to subscribe starts a double opt-in; it does not subscribe anyone. + expect(subscription.status).toBe("pending"); + expect(subscription.confirmation_url).toBe("https://sendly.now/c/tok_1"); + }); + + test("listAll walks every page and yields individual topics", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(topicPage([topic("top_1")], "cur_2")) + .mockResolvedValueOnce(topicPage([topic("top_2"), topic("top_3")], null)); + + const seen: string[] = []; + for await (const item of client.topics.listAll()) seen.push(item.id); + + expect(seen).toEqual(["top_1", "top_2", "top_3"]); + expect(fetchMock.mock.calls).toHaveLength(2); + }); + + test("listAll follows the `cursor` parameter this endpoint names, never `after`", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(topicPage([topic("top_1")], "cur_2")) + .mockResolvedValueOnce(topicPage([topic("top_2")], null)); + + // Draining the generator is the point; the assertions are on the requests it made. + const seen: string[] = []; + for await (const item of client.topics.listAll({ limit: 1 })) seen.push(item.id); + expect(seen).toEqual(["top_1", "top_2"]); + + const second = getCall(fetchMock, 1).url; + expect(second).toContain("cursor=cur_2"); + expect(second).not.toContain("after="); + expect(second).toContain("limit=1"); + }); + + test("listAll stops when a page repeats the cursor it was handed", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(topicPage([topic("top_1")], "cur_stuck")); + + const seen: string[] = []; + for await (const item of client.topics.listAll({ cursor: "cur_stuck" })) seen.push(item.id); + + expect(seen).toEqual(["top_1"]); + expect(fetchMock.mock.calls).toHaveLength(1); + }); +}); diff --git a/src/__tests__/validation.test.ts b/src/__tests__/validation.test.ts new file mode 100644 index 0000000..cf28152 --- /dev/null +++ b/src/__tests__/validation.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "vitest"; +import { SendlyValidationError } from "../index"; +import type { EmailValidationResultListV1 } from "../types"; +import { getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers"; + +type ValidationResult = EmailValidationResultListV1["data"][number]; + +function validation(email: string, verdict: ValidationResult["verdict"]): ValidationResult { + return { + email, + verdict, + is_disposable: false, + is_role_address: false, + is_personal: true, + has_mx_records: verdict !== "undeliverable", + reasons: [], + contact_id: null, + }; +} + +/** + * One page of a run's results. + * + * Deliberately not `helpers.cursorPage`: this endpoint's envelope names the + * next page `cursor`, not `next_cursor`, so the shared builder would describe a + * shape the API never sends. + */ +function resultsPage(data: ValidationResult[], cursor: string | null): Response { + return jsonResponse(200, { data, cursor, has_more: cursor !== null }); +} + +describe("validation resource (/api/v1)", () => { + test("validateEmails POSTs the batch to /api/v1/email-validations", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { results: [validation("a@example.com", "deliverable")] })); + + const batch = await client.validation.validateEmails({ emails: ["a@example.com"] }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/email-validations"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ emails: ["a@example.com"] }); + expect(batch.results[0]?.verdict).toBe("deliverable"); + }); + + test("`unknown` is carried through as its own verdict, distinct from `undeliverable`", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + results: [validation("dns-timeout@example.com", "unknown"), validation("nope@example.com", "undeliverable")], + }), + ); + + const batch = await client.validation.validateEmails({ emails: ["dns-timeout@example.com", "nope@example.com"] }); + + expect(batch.results.map((r) => r.verdict)).toEqual(["unknown", "undeliverable"]); + }); + + test("a batch over the 50-address ceiling surfaces the 422 problem", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + problemResponse(422, { + type: "https://docs.sendly.now/errors/validation_error", + title: "Validation Error", + detail: "`emails` must contain at most 50 items.", + code: "validation_error", + request_id: "req_val_422", + }), + ); + + const error = await rejection( + client.validation.validateEmails({ emails: Array.from({ length: 51 }, (_, i) => `u${i}@example.com`) }), + ); + expect(error).toBeInstanceOf(SendlyValidationError); + expect(error.errorCode).toBe("validation_error"); + }); + + test("getRun GETs the run by id", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "vrun_1", status: "running", processed_count: 12 })); + + const run = await client.validation.getRun("vrun_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/validation-runs/vrun_1"); + expect(init.method).toBe("GET"); + expect(run.status).toBe("running"); + }); + + test("listResults serializes limit, verdict and the `cursor` page parameter", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(resultsPage([], null)); + + await client.validation.listResults("vrun_1", { limit: 50, verdict: "undeliverable", cursor: "cur_1" }); + + const { url } = getCall(fetchMock); + expect(url).toContain("http://localhost/api/v1/validation-runs/vrun_1/results?"); + expect(url).toContain("limit=50"); + expect(url).toContain("verdict=undeliverable"); + expect(url).toContain("cursor=cur_1"); + expect(url).not.toContain("after="); + }); + + test("listResults resolves the envelope itself — the page is not unwrapped to its data array", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(resultsPage([validation("a@example.com", "risky")], "cur_2")); + + const page = await client.validation.listResults("vrun_1"); + + expect(page.has_more).toBe(true); + expect(page.cursor).toBe("cur_2"); + expect(page.data).toHaveLength(1); + }); + + test("listResultsAll pages on `cursor`, not the `after` the other v1 lists take", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(resultsPage([validation("a@example.com", "undeliverable")], "cur_2")) + .mockResolvedValueOnce(resultsPage([validation("b@example.com", "undeliverable")], null)); + + const seen: string[] = []; + for await (const result of client.validation.listResultsAll("vrun_1", { verdict: "undeliverable" })) { + seen.push(result.email); + } + + expect(seen).toEqual(["a@example.com", "b@example.com"]); + expect(fetchMock.mock.calls).toHaveLength(2); + const second = getCall(fetchMock, 1).url; + expect(second).toContain("cursor=cur_2"); + expect(second).not.toContain("after="); + expect(second).toContain("verdict=undeliverable"); + }); + + test("listResultsAll stops on the last page instead of re-fetching it", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(resultsPage([validation("a@example.com", "deliverable")], null)); + + const seen: string[] = []; + for await (const result of client.validation.listResultsAll("vrun_1")) seen.push(result.email); + + expect(seen).toEqual(["a@example.com"]); + expect(fetchMock.mock.calls).toHaveLength(1); + }); +}); diff --git a/src/__tests__/webhooks.test.ts b/src/__tests__/webhooks.test.ts index f1a28f1..a6e4436 100644 --- a/src/__tests__/webhooks.test.ts +++ b/src/__tests__/webhooks.test.ts @@ -1,6 +1,17 @@ import { describe, expect, test } from "vitest"; import { SendlyRateLimitError } from "../index"; -import { getCall, jsonResponse, makeClient } from "./helpers"; +import type { WebhookV1 } from "../types"; +import { cursorPage, getCall, getCallBody, jsonResponse, makeClient } from "./helpers"; + +function webhookV1(id: string): WebhookV1 { + // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter + return { + id, + url: `https://example.com/${id}`, + event_types: ["email.delivered"], + status: "ACTIVE", + } as unknown as WebhookV1; +} describe("webhooks resource", () => { test("create POSTs /api/webhooks", async () => { @@ -60,3 +71,121 @@ describe("webhooks resource", () => { expect(getCall(fetchMock).init.method).toBe("DELETE"); }); }); + +describe("webhooks resource (/api/v1)", () => { + test("createV1 POSTs /api/v1/webhooks and hands back the one-time secret", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(201, { + webhook: { id: "wh_1", url: "https://example.com/hook", event_types: ["email.delivered"], status: "ACTIVE" }, + secret: "whsec_created", + }), + ); + + const created = await client.webhooks.createV1({ + url: "https://example.com/hook", + event_types: ["email.delivered"], + }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/webhooks"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ url: "https://example.com/hook", event_types: ["email.delivered"] }); + // This response and rotateSecretV1 are the only two that carry the secret. + expect(created.secret).toBe("whsec_created"); + expect(created.webhook.id).toBe("wh_1"); + }); + + test("v1 responses are bare bodies — no envelope is unwrapped", async () => { + const { client, fetchMock } = makeClient(); + const body = { + id: "wh_1", + url: "https://example.com/hook", + event_types: ["email.delivered"], + status: "ACTIVE", + }; + fetchMock.mockResolvedValue(jsonResponse(200, body)); + + // A v1 body has no `data` key to unwrap, so unwrapping would lose the whole record. + expect(await client.webhooks.getV1("wh_1")).toEqual(body); + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/webhooks/wh_1"); + // A read never carries the secret. + expect(await client.webhooks.getV1("wh_1")).not.toHaveProperty("secret"); + }); + + test("updateV1 PATCHes the v1 path with the replacement event list", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { + id: "wh_1", + url: "https://example.com/hook", + event_types: ["email.bounced"], + status: "ACTIVE", + }), + ); + + const updated = await client.webhooks.updateV1("wh_1", { event_types: ["email.bounced"], status: "ACTIVE" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/webhooks/wh_1"); + expect(init.method).toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ event_types: ["email.bounced"], status: "ACTIVE" }); + // `event_types` replaces rather than merges — `email.delivered` is gone. + expect(updated.event_types).toEqual(["email.bounced"]); + }); + + test("deleteV1 DELETEs the v1 path and resolves the deletion receipt", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { id: "wh_1", deleted: true })); + + const deleted = await client.webhooks.deleteV1("wh_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/webhooks/wh_1"); + expect(init.method).toBe("DELETE"); + expect(deleted).toEqual({ id: "wh_1", deleted: true }); + }); + + test("rotateSecretV1 POSTs the rotate sub-path and names when the old secret dies", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + jsonResponse(200, { secret: "whsec_rotated", previous_secret_expires_at: "2026-09-06T00:00:00.000Z" }), + ); + + const rotated = await client.webhooks.rotateSecretV1("wh_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/webhooks/wh_1/rotate-secret"); + expect(init.method).toBe("POST"); + expect(rotated.secret).toBe("whsec_rotated"); + // Both signatures ship until this moment; after it the old secret is rejected. + expect(rotated.previous_secret_expires_at).toBe("2026-09-06T00:00:00.000Z"); + }); + + test("listV1 serializes cursor query params", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(cursorPage([], null)); + + await client.webhooks.listV1({ limit: 25, after: "cur_wh" }); + + const { url } = getCall(fetchMock); + expect(url).toContain("http://localhost/api/v1/webhooks?"); + expect(url).toContain("limit=25"); + expect(url).toContain("after=cur_wh"); + }); + + test("listAllV1 walks every page and yields individual webhooks", async () => { + const { client, fetchMock } = makeClient(); + fetchMock + .mockResolvedValueOnce(cursorPage([webhookV1("wh_1")], "cur_2")) + .mockResolvedValueOnce(cursorPage([webhookV1("wh_2"), webhookV1("wh_3")], null)); + + const seen: string[] = []; + for await (const item of client.webhooks.listAllV1({ limit: 1 })) seen.push(item.id); + + expect(seen).toEqual(["wh_1", "wh_2", "wh_3"]); + expect(fetchMock.mock.calls).toHaveLength(2); + expect(getCall(fetchMock, 1).url).toContain("after=cur_2"); + expect(getCall(fetchMock, 1).url).toContain("limit=1"); + }); +}); diff --git a/src/__tests__/workflows.test.ts b/src/__tests__/workflows.test.ts index 398f363..ebc2bcf 100644 --- a/src/__tests__/workflows.test.ts +++ b/src/__tests__/workflows.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { SendlyConflictError } from "../index"; -import type { WorkflowExecutionV1, WorkflowV1 } from "../types"; +import type { ReplaceWorkflowGraphV1Request, WorkflowExecutionV1, WorkflowGraphV1, WorkflowV1 } from "../types"; import { cursorPage, getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers"; function workflow(id: string): WorkflowV1 { @@ -13,6 +13,22 @@ function execution(id: string): WorkflowExecutionV1 { return { id, workflow_id: "wf_1", status: "RUNNING" } as unknown as WorkflowExecutionV1; } +/** The graph's single entry node, in the shape a graph WRITE accepts. */ +const TRIGGER_STEP: ReplaceWorkflowGraphV1Request["steps"][number] = { + id: "st_1", + name: "Signed up", + position: { x: 0, y: 0 }, + type: "TRIGGER", + config: { eventName: "signup" }, +}; + +const GRAPH: WorkflowGraphV1 = { + workflow_id: "wf_1", + version: 7, + steps: [{ ...TRIGGER_STEP, template_id: null }], + transitions: [], +}; + describe("workflows resource (/api/v1)", () => { test("create POSTs /api/v1/workflows and resolves the bare workflow", async () => { const { client, fetchMock } = makeClient(); @@ -138,4 +154,95 @@ describe("workflows resource (/api/v1)", () => { expect(error).toBeInstanceOf(SendlyConflictError); expect(error.errorCode).toBe("conflict"); }); + + test("getGraph GETs the graph sub-path and resolves the bare graph, not an envelope", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, GRAPH)); + + const graph = await client.workflows.getGraph("wf_1"); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/workflows/wf_1/graph"); + expect(init.method).toBe("GET"); + // A v1 body arrives bare: `version` sits at the top level, not under `data`. + expect(graph.version).toBe(7); + expect(graph.steps[0]?.type).toBe("TRIGGER"); + }); + + test("replaceGraph issues a PUT — not a PATCH — to the same path, sending the whole document", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { ...GRAPH, version: 8 })); + + const graph = await client.workflows.replaceGraph("wf_1", { steps: [TRIGGER_STEP], transitions: [] }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/workflows/wf_1/graph"); + expect(init.method).toBe("PUT"); + expect(init.method).not.toBe("PATCH"); + expect(getCallBody(fetchMock)).toEqual({ steps: [TRIGGER_STEP], transitions: [] }); + expect(graph.version).toBe(8); + }); + + test("replaceGraph against a workflow with running executions surfaces the conflict code", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue( + problemResponse(409, { + type: "https://docs.sendly.now/errors/conflict", + title: "Conflict", + detail: "Workflow has running executions.", + code: "conflict", + }), + ); + + const error = await rejection( + client.workflows.replaceGraph("wf_1", { steps: [TRIGGER_STEP], transitions: [] }), + ); + expect(error).toBeInstanceOf(SendlyConflictError); + expect(error.errorCode).toBe("conflict"); + }); + + test("clone POSTs the name to the clone sub-path and resolves the copy", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(201, { id: "wf_2", name: "Onboarding v2", enabled: false })); + + const copy = await client.workflows.clone("wf_1", { name: "Onboarding v2" }); + + const { url, init } = getCall(fetchMock); + expect(url).toBe("http://localhost/api/v1/workflows/wf_1/clone"); + expect(init.method).toBe("POST"); + expect(getCallBody(fetchMock)).toEqual({ name: "Onboarding v2" }); + expect(copy.id).toBe("wf_2"); + // A clone is always born disabled, however the original stood. + expect(copy.enabled).toBe(false); + }); + + test("pause reports the runs it cancelled; resume reports zero", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, { workflow: workflow("wf_1"), cancelled_executions: 23 })); + + const paused = await client.workflows.pause("wf_1"); + + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/workflows/wf_1/pause"); + expect(getCall(fetchMock).init.method).toBe("POST"); + // This is what makes pause different from update({ enabled: false }). + expect(paused.cancelled_executions).toBe(23); + + fetchMock.mockClear(); + fetchMock.mockResolvedValue(jsonResponse(200, { workflow: workflow("wf_1"), cancelled_executions: 0 })); + + const resumed = await client.workflows.resume("wf_1"); + + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/workflows/wf_1/resume"); + expect(getCall(fetchMock).init.method).toBe("POST"); + expect(resumed.cancelled_executions).toBe(0); + }); + + test("the graph and lifecycle routes percent-encode the workflow id", async () => { + const { client, fetchMock } = makeClient(); + fetchMock.mockResolvedValue(jsonResponse(200, GRAPH)); + + await client.workflows.getGraph("wf/../evil"); + + expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/workflows/wf%2F..%2Fevil/graph"); + }); }); diff --git a/src/client.ts b/src/client.ts index 46719ba..74e87de 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { AnalyticsResource } from "./resources/analytics"; import { CampaignsResource } from "./resources/campaigns"; import { ContactsResource } from "./resources/contacts"; +import { DeliverabilityResource } from "./resources/deliverability"; import { DomainsResource } from "./resources/domains"; import { EmailsResource } from "./resources/emails"; import { EventsResource } from "./resources/events"; @@ -8,9 +9,12 @@ import { ListsResource } from "./resources/lists"; import { MailboxesResource } from "./resources/mailboxes"; import { ProjectsResource } from "./resources/projects"; import { SegmentsResource } from "./resources/segments"; +import { SnippetsResource } from "./resources/snippets"; import { SuppressionResource } from "./resources/suppression"; import { TemplatesResource } from "./resources/templates"; +import { TopicsResource } from "./resources/topics"; import { UsageResource } from "./resources/usage"; +import { ValidationResource } from "./resources/validation"; import { VerifyResource } from "./resources/verify"; import { WebhooksResource } from "./resources/webhooks"; import { WorkflowsResource } from "./resources/workflows"; @@ -18,7 +22,7 @@ import { errorFromResponse, SendlyConnectionError, SendlyError } from "./errors" import type { ErrorEnvelope } from "./types"; /** Build-time package version (kept in sync with package.json). */ -export const SDK_VERSION = "1.0.0"; +export const SDK_VERSION = "1.1.0"; /** Default production API base. Override via `baseUrl` for staging or self-hosted deployments. */ export const DEFAULT_BASE_URL = "https://api.sendly.now"; @@ -39,8 +43,14 @@ export interface SendlyClientOptions { export interface RequestOptions { /** Path relative to baseUrl, must start with `/`. */ path: string; - /** HTTP method. */ - method: "GET" | "POST" | "PATCH" | "DELETE"; + /** + * HTTP method. + * + * `PUT` exists for exactly one operation — replacing a workflow graph — and the + * distinction is the point: a graph is replaced whole, never patched, because a + * partial edit to a node list has no meaning without the edges that reference it. + */ + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; /** Optional JSON body. Will be serialized + Content-Type set. */ body?: unknown; /** @@ -76,6 +86,8 @@ export class Sendly { readonly events: EventsResource; readonly verify: VerifyResource; readonly lists: ListsResource; + /** Reusable body fragments a template includes with `{{> name}}`. */ + readonly snippets: SnippetsResource; /** Receiving mailboxes. Reads only — the writes need a user, not an API key. */ readonly mailboxes: MailboxesResource; @@ -91,6 +103,12 @@ export class Sendly { readonly usage: UsageResource; /** The project this key belongs to, on the versioned `/api/v1` surface. */ readonly projects: ProjectsResource; + /** Consent topics and what each contact has said they want. */ + readonly topics: TopicsResource; + /** Address validation — one batch, or a whole list. */ + readonly validation: ValidationResource; + /** Why mail from your domains is or is not arriving. */ + readonly deliverability: DeliverabilityResource; private readonly apiKey: string; private readonly baseUrl: string; @@ -125,6 +143,7 @@ export class Sendly { this.events = new EventsResource(this); this.verify = new VerifyResource(this); this.lists = new ListsResource(this); + this.snippets = new SnippetsResource(this); this.mailboxes = new MailboxesResource(this); this.campaigns = new CampaignsResource(this); @@ -133,6 +152,9 @@ export class Sendly { this.analytics = new AnalyticsResource(this); this.usage = new UsageResource(this); this.projects = new ProjectsResource(this); + this.topics = new TopicsResource(this); + this.validation = new ValidationResource(this); + this.deliverability = new DeliverabilityResource(this); } /** diff --git a/src/index.ts b/src/index.ts index 4a06cb1..40c804d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export { EventsResource } from "./resources/events"; export { VerifyResource } from "./resources/verify"; export { ListsResource } from "./resources/lists"; export { MailboxesResource } from "./resources/mailboxes"; +export { SnippetsResource } from "./resources/snippets"; // /api/v1 resources — bare responses, snake_case fields, RFC 9457 errors. export { CampaignsResource } from "./resources/campaigns"; @@ -33,6 +34,9 @@ export { WorkflowsResource } from "./resources/workflows"; export { AnalyticsResource } from "./resources/analytics"; export { UsageResource } from "./resources/usage"; export { ProjectsResource } from "./resources/projects"; +export { TopicsResource } from "./resources/topics"; +export { ValidationResource } from "./resources/validation"; +export { DeliverabilityResource } from "./resources/deliverability"; export { paginateCursor } from "./pagination"; export type { CursorPage, CursorPageQuery } from "./pagination"; diff --git a/src/resources/campaigns.ts b/src/resources/campaigns.ts index f06113a..7b95820 100644 --- a/src/resources/campaigns.ts +++ b/src/resources/campaigns.ts @@ -4,10 +4,14 @@ import { idemHeader } from "./idempotency"; import type { IdempotencyOptions } from "./idempotency"; import type { CampaignDeletedV1, + CampaignFailureListV1, + CampaignFailureV1, CampaignListV1, + CampaignRetryFailedV1, CampaignStatsV1, CampaignV1, CreateCampaignV1Request, + ListCampaignFailuresV1Query, ListCampaignsV1Query, SendCampaignV1Request, UpdateCampaignV1Request, @@ -131,4 +135,49 @@ export class CampaignsResource { path: `/api/v1/campaigns/${encodeURIComponent(id)}/stats`, }); } + + /** + * The recipients this campaign did not reach, and why. + * + * {@link 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 it is `null` on rows recorded before + * reasons were captured. + * + * Cursor-paginated like every other v1 list, but uniquely it also carries + * `total`: {@link retryFailed} acts on that number, and `has_more` alone + * cannot tell you whether 3 or 30,000 sends failed. + */ + async listFailures(id: string, query?: ListCampaignFailuresV1Query): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/campaigns/${encodeURIComponent(id)}/failures`, + query, + }); + } + + /** Iterate every failed send across pages, yielding one recipient at a time. */ + async *listFailuresAll( + id: string, + query?: ListCampaignFailuresV1Query, + ): AsyncGenerator { + yield* paginateCursor((after) => this.listFailures(id, { ...query, after }), query?.after); + } + + /** + * 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, not re-sent. + * + * The walk runs in the background, so this resolves 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. + */ + async retryFailed(id: string): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/campaigns/${encodeURIComponent(id)}/retry-failed`, + }); + } } diff --git a/src/resources/contacts.ts b/src/resources/contacts.ts index 290976a..11a5751 100644 --- a/src/resources/contacts.ts +++ b/src/resources/contacts.ts @@ -1,16 +1,33 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import { idemHeader } from "./idempotency"; import type { IdempotencyOptions } from "./idempotency"; import type { BulkCreateContactsRequest, BulkDeleteContactsRequest, + ContactDeletedV1, ContactListResponse, + ContactListV1, ContactRecord, + ContactTopicPreferencesV1, + ContactV1, CreateContactRequest, + CreateContactV1Request, ListContactsQuery, + ListContactsV1Query, UpdateContactRequest, + UpdateContactV1Request, } from "../types"; +/** + * 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. + */ export class ContactsResource { constructor(private readonly client: Sendly) {} @@ -91,4 +108,96 @@ export class ContactsResource { noContent: true, }); } + + /** + * List contacts on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after` with no total count, narrowed by + * `search` (case-insensitive substring on the address) and `subscribed`. + * Hold the filters steady for the whole walk — the cursor encodes them, and + * changing one mid-pagination returns `422 validation_error` asking you to + * restart. {@link listAllV1} drives the loop for you. + */ + async listV1(query?: ListContactsV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/contacts", + query, + }); + } + + /** Iterate every v1 contact across pages, yielding one contact at a time. */ + async *listAllV1(query?: ListContactsV1Query): AsyncGenerator { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + + /** + * Create a contact. Only `email` is required — `subscribed` defaults to true + * server-side, and `custom_fields` is arbitrary JSON that templates can read + * back as `{{ variables }}`. + */ + async createV1(body: CreateContactV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/contacts", + body, + }); + } + + /** + * Retrieve a single contact by id. v1 has no lookup-by-address route — reach + * a contact you only know the email of through {@link listV1}'s `search`. + */ + async getV1(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/contacts/${encodeURIComponent(id)}`, + }); + } + + /** + * Patch a contact. Only the fields you send are changed, with two caveats. + * + * `email` is not patchable at all: an address is the contact's identity here, + * 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. Sending a partial object silently drops the rest. + */ + async updateV1(id: string, body: UpdateContactV1Request): Promise { + return this.client.request({ + method: "PATCH", + path: `/api/v1/contacts/${encodeURIComponent(id)}`, + body, + }); + } + + /** + * Delete a contact. Unlike the legacy {@link delete}, this resolves the + * `{ id, deleted }` acknowledgement rather than discarding it. + */ + async deleteV1(id: string): Promise { + return this.client.request({ + method: "DELETE", + path: `/api/v1/contacts/${encodeURIComponent(id)}`, + }); + } + + /** + * 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 nothing 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. + */ + async topicPreferences(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/contacts/${encodeURIComponent(id)}/topics`, + }); + } } diff --git a/src/resources/deliverability.ts b/src/resources/deliverability.ts new file mode 100644 index 0000000..578038f --- /dev/null +++ b/src/resources/deliverability.ts @@ -0,0 +1,105 @@ +import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; +import type { + DeliverabilityDiagnosisV1, + DiagnoseDeliverabilityV1Query, + DmarcReportListV1, + DmarcReportV1, + ListDmarcReportsV1Query, + ListRecipientDomainStatsV1Query, + RecipientDomainStatsListV1, + RecipientDomainStatsV1, +} from "../types"; + +/** + * Deliverability on the `/api/v1` surface — why mail from your domains is, or + * is not, arriving. + * + * Responses are bare v1 bodies (no `{ success, data }` envelope) and errors are + * RFC 9457 problem documents. + */ +export class DeliverabilityResource { + constructor(private readonly client: Sendly) {} + + /** + * Diagnose one of your SENDING domains: 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. + * + * `query.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. + */ + async diagnose(query: DiagnoseDeliverabilityV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/diagnose", + query, + }); + } + + /** + * Delivery outcomes broken out 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 {@link 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. + */ + async listDomainStats(query?: ListRecipientDomainStatsV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/domains", + query, + }); + } + + /** Iterate every recipient-domain row across pages, one day-and-domain at a time. */ + async *listDomainStatsAll( + query?: ListRecipientDomainStatsV1Query, + ): AsyncGenerator { + yield* paginateCursor((after) => this.listDomainStats({ ...query, after }), query?.after); + } + + /** + * DMARC aggregate (RUA) reports that receiving providers have sent about your + * domains, newest reporting 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). + * + * `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. + */ + async listDmarcReports(query?: ListDmarcReportsV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/deliverability/dmarc", + query, + }); + } + + /** Iterate every DMARC report across pages, one report at a time. */ + async *listDmarcReportsAll(query?: ListDmarcReportsV1Query): AsyncGenerator { + yield* paginateCursor((after) => this.listDmarcReports({ ...query, after }), query?.after); + } +} diff --git a/src/resources/domains.ts b/src/resources/domains.ts index b7cbf6a..cd9972b 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -1,12 +1,28 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import type { AddDomainRequest, + AssignDomainStreamRequest, + CreateDomainV1Request, + DomainDeletedV1, DomainListResponse, + DomainListV1, DomainRecord, DomainSetupSession, + DomainV1, DomainVerificationStatus, + ListDomainsV1Query, } from "../types"; +/** + * 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. + */ export class DomainsResource { constructor(private readonly client: Sendly) {} @@ -80,6 +96,30 @@ export class DomainsResource { return this.client.unwrap(envelope); } + /** + * 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: null` 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. + */ + async assignStream(id: string, body: AssignDomainStreamRequest): Promise { + const envelope = await this.client.request<{ success: true; data: DomainRecord }>({ + method: "PATCH", + path: `/api/domains/${encodeURIComponent(id)}`, + body, + }); + return this.client.unwrap(envelope); + } + /** Delete a domain. */ async delete(id: string): Promise { await this.client.request({ @@ -87,4 +127,92 @@ export class DomainsResource { path: `/api/domains/${encodeURIComponent(id)}`, }); } + + /** + * List sending domains, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. {@link listAllV1} + * drives the loop 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. + */ + async listV1(query?: ListDomainsV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/domains", + query, + }); + } + + /** Iterate every sending domain across pages, yielding one domain at a time. */ + async *listAllV1(query?: ListDomainsV1Query): AsyncGenerator { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + + /** + * 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 {@link verifyV1} 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. + */ + async createV1(body: CreateDomainV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/domains", + body, + }); + } + + /** Retrieve a single sending domain. */ + async getV1(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/domains/${encodeURIComponent(id)}`, + }); + } + + /** + * Re-read the domain's state from SES and DNS, and resolve the refreshed + * document. + * + * 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. + */ + async verifyV1(id: string): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/domains/${encodeURIComponent(id)}/verify`, + }); + } + + /** + * Remove a sending domain. Resolves `{ id, deleted }`. + * + * 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. + */ + async deleteV1(id: string): Promise { + return this.client.request({ + method: "DELETE", + path: `/api/v1/domains/${encodeURIComponent(id)}`, + }); + } } diff --git a/src/resources/emails.ts b/src/resources/emails.ts index da3403f..2c46dbf 100644 --- a/src/resources/emails.ts +++ b/src/resources/emails.ts @@ -4,7 +4,7 @@ import type { IdempotencyOptions } from "./idempotency"; import type { BatchSendRequest, BatchSendResponse, - EmailGetResponse, + EmailDetailResponse, EmailListResponse, EmailTestV1, EmailV1, @@ -12,8 +12,8 @@ import type { SendEmailData, SendEmailRequest, SendEmailV1Request, + EmailResponse, SendTestEmailV1Request, - SuccessEmpty, } from "../types"; // `IdempotencyOptions` now lives in ./idempotency, shared with every other @@ -103,17 +103,31 @@ export class EmailsResource { }); } - /** Fetch a single email and its delivery events. */ - async get(id: string): Promise { - return this.client.request({ + /** + * 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 along with it. + */ + async get(id: string): Promise { + return this.client.request({ method: "GET", path: `/api/emails/${encodeURIComponent(id)}`, }); } - /** Cancel a scheduled (PENDING) email before it fires. */ - async cancelSchedule(id: string): Promise { - return this.client.request({ + /** + * Cancel a scheduled (PENDING) email before it fires. + * + * Resolves the email itself, not an empty acknowledgement: the contract has + * always published `EmailResponse` here, and the caller wants the row's new + * status more than it wants a `{ success: true }` it already inferred from the + * absence of an exception. + */ + async cancelSchedule(id: string): Promise { + return this.client.request({ method: "DELETE", path: `/api/emails/${encodeURIComponent(id)}/schedule`, }); diff --git a/src/resources/lists.ts b/src/resources/lists.ts index 2203620..736e9b8 100644 --- a/src/resources/lists.ts +++ b/src/resources/lists.ts @@ -1,16 +1,30 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import type { + CreateListV1Request, + EmailValidationRunV1, + ListDeletedV1, + ListListV1, + ListListsV1Query, ListSubscribeData, ListSubscribeRequest, ListSubscribeResponse, ListUnsubscribeData, ListUnsubscribeRequest, ListUnsubscribeResponse, + ListV1, + UpdateListV1Request, } from "../types"; /** - * Subscription management for a mailing list, on the legacy `/api/*` surface - * (envelope responses, camelCase — the SDK unwraps to `data`). + * Subscriber lists, on both surfaces. + * + * {@link subscribe} and {@link unsubscribe} speak the legacy `/api/*` dialect + * (camelCase inside a `{ success, data }` envelope the SDK unwraps) and accept + * SENDING_ONLY keys. The `V1`-suffixed methods manage the lists themselves on + * `/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. */ export class ListsResource { constructor(private readonly client: Sendly) {} @@ -22,8 +36,8 @@ export class ListsResource { * **Double opt-in.** When the list has `doubleOptIn` enabled the membership * is created as `PENDING` and the result carries a `confirmToken`. Sendly * does **not** send the confirmation email — your application must deliver - * `/api/lists/confirm?token=` to the contact itself. The token - * is valid for 24 hours. + * `/api/lists/confirm-subscription?token=` to the contact + * itself. The token is valid for 24 hours. * * **Re-subscribing after an opt-out.** If the email already holds an * `UNSUBSCRIBED` membership on this list, the call fails with @@ -54,4 +68,88 @@ export class ListsResource { }); return this.client.unwrap(envelope); } + + /** + * List the project's subscriber lists on the `/api/v1` surface. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold the + * arguments steady for the whole walk — changing them mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query?: ListListsV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/lists", + query, + }); + } + + /** Iterate every list across pages, yielding one list at a time. */ + async *listAllV1(query?: ListListsV1Query): AsyncGenerator { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + + /** + * Create a list. Only `name` is required; `double_opt_in` defaults to false. + * + * Turning double opt-in on does not make Sendly send anything — it only + * changes {@link subscribe} to create the membership as `PENDING` and hand + * back the `confirmToken` your application delivers. + */ + async createV1(body: CreateListV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/lists", + body, + }); + } + + /** + * Retrieve 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. + */ + async getV1(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/lists/${encodeURIComponent(id)}`, + }); + } + + /** + * 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. + */ + async updateV1(id: string, body: UpdateListV1Request): Promise { + return this.client.request({ + method: "PATCH", + path: `/api/v1/lists/${encodeURIComponent(id)}`, + body, + }); + } + + /** Delete a list. Resolves `{ id, deleted }`. Removes the list, not its contacts. */ + async deleteV1(id: string): Promise { + return this.client.request({ + method: "DELETE", + path: `/api/v1/lists/${encodeURIComponent(id)}`, + }); + } + + /** + * 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.getRun`. + */ + async startValidationRun(id: string): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/lists/${encodeURIComponent(id)}/validation-runs`, + }); + } } diff --git a/src/resources/mailboxes.ts b/src/resources/mailboxes.ts index 61c3211..3b2369a 100644 --- a/src/resources/mailboxes.ts +++ b/src/resources/mailboxes.ts @@ -1,20 +1,37 @@ import type { Sendly } from "../client"; -import type { AppPasswordRecord, MailboxDetail, MailboxRecord } from "../types"; +import type { + AppPasswordRecord, + ComposeMailboxMessageRequest, + DraftMailboxMessageRequest, + MailboxDetail, + MailboxRecord, + paths, +} from "../types"; + +/** What {@link MailboxesResource.sendMessage} resolves once the message is submitted. */ +export type MailboxMessageSubmitted = + paths["/api/mailboxes/{id}/messages"]["post"]["responses"][201]["content"]["application/json"]["data"]; + +/** What {@link MailboxesResource.draftMessage} resolves — suggested text, and `sent: false`. */ +export type MailboxMessageDraft = + paths["/api/mailboxes/{id}/drafts"]["post"]["responses"][200]["content"]["application/json"]["data"]; /** - * Receiving mailboxes on the project's verified domains. + * Receiving mailboxes on the project's verified domains, plus the two + * composition operations an API key may drive. * - * READ ONLY, and deliberately so. Creating and deleting a mailbox, and minting - * or revoking an app password, all resolve the acting project admin from the - * session user; an API key carries no user, so those routes answer `401` to any - * `sk_` key however broad its scopes. The contract records that — they publish - * `SessionAuth` without `ApiKeyAuth` — and this SDK authenticates only with API - * keys, so a `create`/`delete` here could never succeed. They are listed in the - * contract suite's `NOT_SDK_CALLABLE` rather than shipped as methods that - * always throw. + * MAILBOX LIFECYCLE is what stays out of reach: creating and deleting a + * mailbox, and minting or revoking an app password, all resolve the acting + * project admin from the session user; an API key carries no user, so those + * routes answer `401` to any `sk_` key however broad its scopes. The contract + * records that — they publish `SessionAuth` without `ApiKeyAuth` — and this SDK + * authenticates only with API keys, so a `create`/`delete` here could never + * succeed. They are listed in the contract suite's `NOT_SDK_CALLABLE` rather + * than shipped as methods that always throw. * - * The three reads below are a different case: their membership check is - * conditional, so a key really can call them. + * Everything below is a different case — the reads' membership check is + * conditional, and {@link sendMessage} / {@link draftMessage} publish + * `ApiKeyAuth` outright — so a key really can call them. */ export class MailboxesResource { constructor(private readonly client: Sendly) {} @@ -70,4 +87,57 @@ export class MailboxesResource { }); return this.client.unwrap(envelope); } + + /** + * 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. + */ + async sendMessage(id: string, body: ComposeMailboxMessageRequest): Promise { + const envelope = await this.client.request<{ success: true; data: MailboxMessageSubmitted }>({ + method: "POST", + path: `/api/mailboxes/${encodeURIComponent(id)}/messages`, + body, + }); + return this.client.unwrap(envelope); + } + + /** + * 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 {@link sendMessage} + * 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. + */ + async draftMessage(id: string, body: DraftMailboxMessageRequest): Promise { + const envelope = await this.client.request<{ success: true; data: MailboxMessageDraft }>({ + method: "POST", + path: `/api/mailboxes/${encodeURIComponent(id)}/drafts`, + body, + }); + return this.client.unwrap(envelope); + } } diff --git a/src/resources/snippets.ts b/src/resources/snippets.ts new file mode 100644 index 0000000..4932bf6 --- /dev/null +++ b/src/resources/snippets.ts @@ -0,0 +1,74 @@ +import type { Sendly } from "../client"; +import type { + CreateSnippetRequest, + ListSnippetsQuery, + SnippetListResponse, + SnippetRecord, + UpdateSnippetRequest, +} from "../types"; + +/** + * Snippets — 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. + */ +export class SnippetsResource { + constructor(private readonly client: Sendly) {} + + /** + * Create a snippet. `name` is the literal identifier templates include with + * `{{> name}}` and is unique within the project, so a clash answers 409. + */ + async create(body: CreateSnippetRequest): Promise { + const envelope = await this.client.request<{ success: true; data: SnippetRecord }>({ + method: "POST", + path: "/api/snippets", + body, + }); + return this.client.unwrap(envelope); + } + + /** List snippets with cursor pagination (`limit`/`cursor`) + optional `search` over name and description. */ + async list(query?: ListSnippetsQuery): Promise { + return this.client.request({ + method: "GET", + path: "/api/snippets", + query, + }); + } + + /** Fetch a single snippet by id. */ + async get(id: string): Promise { + const envelope = await this.client.request<{ success: true; data: SnippetRecord }>({ + method: "GET", + path: `/api/snippets/${encodeURIComponent(id)}`, + }); + return this.client.unwrap(envelope); + } + + /** Patch an existing snippet. */ + async update(id: string, body: UpdateSnippetRequest): Promise { + const envelope = await this.client.request<{ success: true; data: SnippetRecord }>({ + method: "PATCH", + path: `/api/snippets/${encodeURIComponent(id)}`, + body, + }); + return this.client.unwrap(envelope); + } + + /** + * Delete a snippet. The API answers 200 with `{ success, data: { id } }`; the + * SDK resolves void. Templates that still include it keep rendering — an + * absent snippet renders as an empty string, like an absent variable. + */ + async delete(id: string): Promise { + await this.client.request({ + method: "DELETE", + path: `/api/snippets/${encodeURIComponent(id)}`, + noContent: true, + }); + } +} diff --git a/src/resources/suppression.ts b/src/resources/suppression.ts index bbab19b..52cb195 100644 --- a/src/resources/suppression.ts +++ b/src/resources/suppression.ts @@ -1,12 +1,28 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import type { AddSuppressionRequest, + CreateSuppressionV1Request, ListSuppressionsQuery, + ListSuppressionsV1Query, SuppressionCheckResponse, + SuppressionDeletedV1, SuppressionListResponse, + SuppressionListV1, SuppressionRecord, + SuppressionV1, } from "../types"; +/** + * The project suppression list — the addresses no send may reach — in both + * dialects. + * + * 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. + */ export class SuppressionResource { constructor(private readonly client: Sendly) {} @@ -45,4 +61,73 @@ export class SuppressionResource { noContent: true, }); } + + /** + * List suppressed addresses, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. Hold `reason` + * steady for the whole walk — changing it mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query?: ListSuppressionsV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/suppressions", + query, + }); + } + + /** Iterate every suppressed address across pages, yielding one record at a time. */ + async *listAllV1(query?: ListSuppressionsV1Query): AsyncGenerator { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + + /** + * 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. + */ + async createV1(body: CreateSuppressionV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/suppressions", + body, + }); + } + + /** + * Retrieve 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. + */ + async getV1(email: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/suppressions/${encodeURIComponent(email)}`, + }); + } + + /** + * Un-suppress an address: mail can flow to it again. Resolves + * `{ email, deleted }`. + * + * 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. + */ + async deleteV1(email: string): Promise { + return this.client.request({ + method: "DELETE", + path: `/api/v1/suppressions/${encodeURIComponent(email)}`, + }); + } } diff --git a/src/resources/templates.ts b/src/resources/templates.ts index da81527..7714e58 100644 --- a/src/resources/templates.ts +++ b/src/resources/templates.ts @@ -1,12 +1,28 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import type { CreateTemplateRequest, + CreateTemplateV1Request, ListTemplatesQuery, + ListTemplatesV1Query, + TemplateDeletedV1, TemplateListResponse, + TemplateListV1, TemplateRecord, + TemplateV1, UpdateTemplateRequest, + UpdateTemplateV1Request, } from "../types"; +/** + * 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. + */ export class TemplatesResource { constructor(private readonly client: Sendly) {} @@ -20,7 +36,7 @@ export class TemplatesResource { return this.client.unwrap(envelope); } - /** List templates with cursor pagination (`limit`/`cursor`) + optional type filter. */ + /** List templates with cursor pagination (`limit`/`cursor`) + optional `emailCategory` filter. */ async list(query?: ListTemplatesQuery): Promise { return this.client.request({ method: "GET", @@ -56,4 +72,81 @@ export class TemplatesResource { noContent: true, }); } + + /** + * List templates, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. `search` here + * matches the name only — narrower than the dashboard's search, which also + * reads description and subject. Hold `search` and `email_category` steady + * for the whole walk; changing either mid-pagination returns + * `422 validation_error` asking you to restart. {@link listAllV1} drives the + * loop for you. + */ + async listV1(query?: ListTemplatesV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/templates", + query, + }); + } + + /** Iterate every template across pages, yielding one template at a time. */ + async *listAllV1(query?: ListTemplatesV1Query): AsyncGenerator { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + + /** + * Create a template. `email_category` defaults to `MARKETING` server-side. + * + * 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. + */ + async createV1(body: CreateTemplateV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/templates", + body, + }); + } + + /** Retrieve a single template. */ + async getV1(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/templates/${encodeURIComponent(id)}`, + }); + } + + /** + * Patch a template. Only the fields you send are changed. + * + * 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. + */ + async updateV1(id: string, body: UpdateTemplateV1Request): Promise { + return this.client.request({ + method: "PATCH", + path: `/api/v1/templates/${encodeURIComponent(id)}`, + body, + }); + } + + /** + * Delete a template. Resolves `{ id, deleted }` — the legacy `delete` above + * discards that body, 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. + */ + async deleteV1(id: string): Promise { + return this.client.request({ + method: "DELETE", + path: `/api/v1/templates/${encodeURIComponent(id)}`, + }); + } } diff --git a/src/resources/topics.ts b/src/resources/topics.ts new file mode 100644 index 0000000..e8fa933 --- /dev/null +++ b/src/resources/topics.ts @@ -0,0 +1,123 @@ +import type { Sendly } from "../client"; +import type { + CreateTopicV1Request, + ListTopicsV1Query, + SetTopicSubscriptionV1Request, + TopicListV1, + TopicSubscriptionV1, + TopicV1, + UpdateTopicV1Request, +} from "../types"; + +/** + * Topics on the `/api/v1` surface — 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. + */ +export class TopicsResource { + constructor(private readonly client: Sendly) {} + + /** + * List topics, newest first. + * + * Archived topics are omitted unless `include_archived` asks for them. There + * is no delete — archiving is the retire button, because a topic is where + * people's answers are recorded. {@link listAll} drives the loop for you. + * + * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + */ + async list(query?: ListTopicsV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/topics", + query, + }); + } + + /** + * Iterate every topic across pages, yielding one topic at a time. + * + * The walk is written out here rather than delegated to `paginateCursor` + * because this endpoint names its cursor `cursor` on both sides — the query + * parameter and the response field — where every other v1 list takes `after` + * and answers `next_cursor`. + */ + async *listAll(query?: ListTopicsV1Query): AsyncGenerator { + let cursor = query?.cursor; + for (;;) { + const page = await this.list({ ...query, cursor }); + for (const topic of page.data) { + yield topic; + } + const next = page.cursor; + // A page that repeats the cursor it was handed would otherwise spin forever. + if (!page.has_more || next === null || next === cursor) return; + cursor = next; + } + } + + /** + * Create a topic. + * + * `key` is the stable name every preference form and integration refers to, + * so it survives a rename of `name` and cannot be changed afterwards. + * + * `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. + */ + async create(body: CreateTopicV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/topics", + body, + }); + } + + /** Retrieve a single topic. */ + async get(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/topics/${encodeURIComponent(id)}`, + }); + } + + /** + * Patch a topic. Only the fields you send are changed. + * + * `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. + */ + async update(id: string, body: UpdateTopicV1Request): Promise { + return this.client.request({ + method: "PATCH", + path: `/api/v1/topics/${encodeURIComponent(id)}`, + body, + }); + } + + /** + * Record what one contact wants on one topic. The two directions are not + * symmetric, on purpose. + * + * `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. + */ + async setSubscription(id: string, body: SetTopicSubscriptionV1Request): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/topics/${encodeURIComponent(id)}/subscriptions`, + body, + }); + } +} diff --git a/src/resources/validation.ts b/src/resources/validation.ts new file mode 100644 index 0000000..517986f --- /dev/null +++ b/src/resources/validation.ts @@ -0,0 +1,102 @@ +import type { Sendly } from "../client"; +import type { + EmailValidationBatchV1, + EmailValidationResultListV1, + EmailValidationResultV1, + EmailValidationRunV1, + ListValidationResultsV1Query, + ValidateEmailsV1Request, +} from "../types"; + +/** + * Email validation on the `/api/v1` surface — check addresses before you mail + * them, and read back what a bulk run found. + * + * Responses are bare v1 bodies (no `{ success, data }` envelope) and errors are + * RFC 9457 problem documents. + */ +export class ValidationResource { + constructor(private readonly client: Sendly) {} + + /** + * 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 + * (`lists.startValidationRun`) 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. + */ + async validateEmails(body: ValidateEmailsV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/email-validations", + body, + }); + } + + /** + * Retrieve a bulk validation run: how far it has got, and what it found. + * + * The other way a run starts is `lists.startValidationRun`, 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. + */ + async getRun(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/validation-runs/${encodeURIComponent(id)}`, + }); + } + + /** + * 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. + * + * This list pages on `cursor`, not the `after` every other v1 collection + * takes, and its envelope carries the next page under `cursor` rather than + * `next_cursor`. {@link listResultsAll} drives that loop for you. + */ + async listResults(id: string, query?: ListValidationResultsV1Query): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/validation-runs/${encodeURIComponent(id)}/results`, + query, + }); + } + + /** + * Iterate every result across pages, yielding one address's verdict at a time. + * + * Hand-rolled rather than routed through `paginateCursor`: the shared helper + * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on + * both sides, so the helper would send an ignored parameter and re-fetch page + * one forever. Stops on `has_more: false`, a null cursor, or a cursor the + * server repeats. + */ + async *listResultsAll( + id: string, + query?: ListValidationResultsV1Query, + ): AsyncGenerator { + let cursor = query?.cursor; + for (;;) { + const page = await this.listResults(id, { ...query, cursor }); + for (const result of page.data ?? []) { + yield result; + } + const next = page.cursor; + if (!page.has_more || next === null || next === undefined || next === cursor) return; + cursor = next; + } + } +} diff --git a/src/resources/webhooks.ts b/src/resources/webhooks.ts index a03a57e..8ed72fb 100644 --- a/src/resources/webhooks.ts +++ b/src/resources/webhooks.ts @@ -1,13 +1,22 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import type { CreateWebhookRequest, + CreateWebhookV1Request, + ListWebhooksV1Query, UpdateWebhookRequest, + UpdateWebhookV1Request, WebhookCallsListResponse, + WebhookCreatedV1, WebhookCreateResponse, + WebhookDeletedV1, WebhookGetResponse, WebhookListResponse, + WebhookListV1, WebhookRecord, WebhookRotateSecretResponse, + WebhookSecretRotatedV1, + WebhookV1, } from "../types"; export type ListWebhookCallsQuery = { @@ -15,6 +24,15 @@ export type ListWebhookCallsQuery = { cursor?: string; }; +/** + * Webhook endpoints, 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. + */ export class WebhooksResource { constructor(private readonly client: Sendly) {} @@ -81,4 +99,104 @@ export class WebhooksResource { query, }); } + + /** + * List webhook endpoints, newest first. + * + * Cursor-paginated on `limit` + `after`, with no total count. + * {@link listAllV1} drives the loop for you. Signing secrets are not on this + * response — see {@link rotateSecretV1} if you have lost one. + */ + async listV1(query?: ListWebhooksV1Query): Promise { + return this.client.request({ + method: "GET", + path: "/api/v1/webhooks", + query, + }); + } + + /** Iterate every webhook endpoint across pages, yielding one endpoint at a time. */ + async *listAllV1(query?: ListWebhooksV1Query): AsyncGenerator { + yield* paginateCursor((after) => this.listV1({ ...query, after }), query?.after); + } + + /** + * Register an endpoint to receive HMAC-signed deliveries for the events named + * in `event_types`. + * + * Resolves `{ webhook, secret }`, and this is one of only two calls that ever + * carry the signing secret — {@link rotateSecretV1} 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 + * `verifySignature` to authenticate the deliveries that arrive at your + * endpoint. + */ + async createV1(body: CreateWebhookV1Request): Promise { + return this.client.request({ + method: "POST", + path: "/api/v1/webhooks", + body, + }); + } + + /** Retrieve a single webhook endpoint. The signing secret is not on this response. */ + async getV1(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/webhooks/${encodeURIComponent(id)}`, + }); + } + + /** + * 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. + */ + async updateV1(id: string, body: UpdateWebhookV1Request): Promise { + return this.client.request({ + method: "PATCH", + path: `/api/v1/webhooks/${encodeURIComponent(id)}`, + body, + }); + } + + /** + * 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. Resolves `{ id, deleted }`. Deliveries already in flight are not + * recalled, so the endpoint may still receive an event shortly after this. + */ + async deleteV1(id: string): Promise { + return this.client.request({ + method: "DELETE", + path: `/api/v1/webhooks/${encodeURIComponent(id)}`, + }); + } + + /** + * Mint a fresh signing secret for an endpoint. + * + * The new plaintext is returned exactly once, here — this and + * {@link createV1} 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 + * `verifySignature`. 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. + */ + async rotateSecretV1(id: string): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/webhooks/${encodeURIComponent(id)}/rotate-secret`, + }); + } } diff --git a/src/resources/workflows.ts b/src/resources/workflows.ts index e5d807a..b98ed36 100644 --- a/src/resources/workflows.ts +++ b/src/resources/workflows.ts @@ -1,15 +1,19 @@ import type { Sendly } from "../client"; import { paginateCursor } from "../pagination"; import type { + CloneWorkflowV1Request, CreateWorkflowV1Request, ListWorkflowExecutionsV1Query, ListWorkflowsV1Query, + ReplaceWorkflowGraphV1Request, StartWorkflowExecutionV1Request, UpdateWorkflowV1Request, WorkflowDeletedV1, WorkflowExecutionListV1, WorkflowExecutionV1, + WorkflowGraphV1, WorkflowListV1, + WorkflowStateChangeV1, WorkflowStatsV1, WorkflowStatsV1Query, WorkflowV1, @@ -139,4 +143,99 @@ export class WorkflowsResource { query, }); } + + /** + * Every step in the workflow — including its `TRIGGER` entry node — 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 {@link replaceGraph} — read, edit one step, + * send it back. + */ + async getGraph(id: string): Promise { + return this.client.request({ + method: "GET", + path: `/api/v1/workflows/${encodeURIComponent(id)}/graph`, + }); + } + + /** + * 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. {@link pause} first. + */ + async replaceGraph(id: string, body: ReplaceWorkflowGraphV1Request): Promise { + return this.client.request({ + method: "PUT", + path: `/api/v1/workflows/${encodeURIComponent(id)}/graph`, + body, + }); + } + + /** + * 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 `. + */ + async clone(id: string, body: CloneWorkflowV1Request): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/clone`, + body, + }); + } + + /** + * Disable the workflow *and cancel every `RUNNING`/`WAITING` execution in it*, + * resolving `{ 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: {@link resume} re-opens the workflow to new + * runs, it does not put the cancelled contacts back where they were. + */ + async pause(id: string): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/pause`, + }); + } + + /** + * 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. + */ + async resume(id: string): Promise { + return this.client.request({ + method: "POST", + path: `/api/v1/workflows/${encodeURIComponent(id)}/resume`, + }); + } } diff --git a/src/types.generated.ts b/src/types.generated.ts index 578f29e..945f561 100644 --- a/src/types.generated.ts +++ b/src/types.generated.ts @@ -4,7 +4,7 @@ */ export interface paths { - "/api/contacts": { + "/api/v1/campaigns": { parameters: { query?: never; header?: never; @@ -12,55 +12,69 @@ export interface paths { cookie?: never; }; /** - * List contacts - * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. + * List campaigns + * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - get: operations["listContacts"]; + get: operations["v1ListCampaigns"]; put?: never; /** - * Create a contact - * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. + * Create a campaign + * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * The `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. + * + * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["createContact"]; + post: operations["v1CreateCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/bulk": { + "/api/v1/campaigns/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Bulk-create contacts - * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. + * Retrieve a campaign + * @description Fetch one campaign, including its materialized delivery counters. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["bulkCreateContacts"]; + get: operations["v1GetCampaign"]; + put?: never; + post?: never; /** - * Bulk-delete contacts - * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. + * Delete a campaign + * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["bulkDeleteContacts"]; + delete: operations["v1DeleteCampaign"]; options?: never; head?: never; - patch?: never; + /** + * Update a campaign + * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * + * Changing `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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + */ + patch: operations["v1UpdateCampaign"]; trace?: never; }; - "/api/contacts/upsert": { + "/api/v1/campaigns/{id}/send": { parameters: { query?: never; header?: never; @@ -70,131 +84,115 @@ export interface paths { get?: never; put?: never; /** - * Create or update a contact by email - * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. + * Send or schedule a campaign + * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * **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. + * + * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * + * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. */ - post: operations["upsertContact"]; + post: operations["v1SendCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/{id}": { + "/api/v1/campaigns/{id}/cancel": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a contact - * @description Requires the `contacts:read` scope — View your contacts and their custom fields. - */ - get: operations["getContact"]; + get?: never; put?: never; - post?: never; /** - * Delete a contact - * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). + * Cancel a campaign + * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["deleteContact"]; + post: operations["v1CancelCampaign"]; + delete?: never; options?: never; head?: never; - /** - * Update a contact - * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - patch: operations["updateContact"]; + patch?: never; trace?: never; }; - "/api/domains": { + "/api/v1/campaigns/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List sending domains - * @description List all domains for the authenticated project. - * - * Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["listDomains"]; + get?: never; put?: never; /** - * Add a sending domain - * @description Register a new domain with SES and persist its DKIM tokens. + * Pause a sending campaign + * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["addDomain"]; + post: operations["v1PauseCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}": { + "/api/v1/campaigns/{id}/resume": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a sending domain - * @description Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["getDomain"]; + get?: never; put?: never; - post?: never; /** - * Remove a sending domain - * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. + * Resume a paused campaign + * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["deleteDomain"]; + post: operations["v1ResumeCampaign"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/dodomain-session": { + "/api/v1/campaigns/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Start guided DNS setup - * @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. - * - * The 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. + * Retrieve campaign statistics + * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. * - * `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. + * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["startDomainSetup"]; + get: operations["v1GetCampaignStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/verify": { + "/api/v1/campaigns/{id}/failures": { parameters: { query?: never; header?: never; @@ -202,79 +200,83 @@ export interface paths { cookie?: never; }; /** - * Read SES verification status - * @description Read the current SES verification status without forcing a refresh. + * List a campaign's failed sends + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - get: operations["getDomainVerification"]; - put?: never; - /** - * Trigger SES verification - * @description Force a refresh of the domain's SES verification status. + * `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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Cursor-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. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["verifyDomain"]; + get: operations["v1ListCampaignFailures"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails": { + "/api/v1/campaigns/{id}/retry-failed": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List emails - * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. - * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. - */ - get: operations["listEmails"]; + get?: never; put?: never; /** - * Send a single transactional email - * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * Retry a campaign's failed sends + * @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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * The 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. + * + * Only 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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["sendEmail"]; + post: operations["v1RetryCampaignFailures"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/batch": { + "/api/v1/segments": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List segments + * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * + * `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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. + */ + get: operations["v1ListSegments"]; put?: never; /** - * Send a batch of emails - * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. + * Create a segment + * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. * - * The 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. + * A `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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - post: operations["sendEmailBatch"]; + post: operations["v1CreateSegment"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}": { + "/api/v1/segments/{id}": { parameters: { query?: never; header?: never; @@ -282,89 +284,129 @@ export interface paths { cookie?: never; }; /** - * Get a single email - * @description Fetch one email along with its delivery events. + * Retrieve a segment + * @description Fetch one segment, including its saved `condition` and materialized `member_count`. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["getEmail"]; + get: operations["v1GetSegment"]; put?: never; post?: never; - delete?: never; + /** + * Delete a segment + * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + delete: operations["v1DeleteSegment"]; options?: never; head?: never; - patch?: never; + /** + * Update a segment + * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * + * `type` is not accepted here — see the create operation. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + patch: operations["v1UpdateSegment"]; trace?: never; }; - "/api/emails/{id}/schedule": { + "/api/v1/segments/{id}/contacts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Cancel a scheduled (still-PENDING) email - * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. + * List the contacts in a segment + * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. + * + * Cursors 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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - delete: operations["cancelScheduledEmail"]; + get: operations["v1ListSegmentContacts"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/lists/{id}/subscribe": { + "/api/v1/workflows": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Subscribe a contact to a list - * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm?token=` to the contact itself. + * List workflows + * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. * - * **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. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1ListWorkflows"]; + put?: never; + /** + * Create a workflow + * @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`. * - * `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. + * Pass `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["subscribeToList"]; + post: operations["v1CreateWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/lists/{id}/unsubscribe": { + "/api/v1/workflows/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Retrieve a workflow + * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. + * + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1GetWorkflow"]; put?: never; + post?: never; /** - * Unsubscribe a contact from a list - * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. + * Delete a workflow + * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. * - * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["unsubscribeFromList"]; - delete?: never; + delete: operations["v1DeleteWorkflow"]; options?: never; head?: never; - patch?: never; + /** + * Update a workflow + * @description Sparse update — omitted fields are left unchanged. + * + * Two 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. + * + * `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + patch: operations["v1UpdateWorkflow"]; trace?: never; }; - "/api/mailboxes": { + "/api/v1/workflows/{id}/executions": { parameters: { query?: never; header?: never; @@ -372,69 +414,75 @@ export interface paths { cookie?: never; }; /** - * List mailboxes - * @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. - * - * This 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. + * List a workflow's executions + * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listMailboxes"]; + get: operations["v1ListWorkflowExecutions"]; put?: never; /** - * Create a mailbox - * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. - * - * Three consequences worth knowing before you call it: - * - * - **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. - * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. - * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. - * - * Retrying 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. + * Start a workflow for a contact + * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. * - * `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. + * 409 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createMailbox"]; + post: operations["v1StartWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}": { + "/api/v1/workflows/executions/{execution_id}/cancel": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get a mailbox - * @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. + * Cancel a workflow execution + * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - get: operations["getMailbox"]; - put?: never; - post?: never; + post: operations["v1CancelWorkflowExecution"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/{id}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; /** - * Delete a mailbox - * @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. + * Retrieve workflow statistics + * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. * - * Requires an admin of the project. + * The 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - delete: operations["deleteMailbox"]; + get: operations["v1GetWorkflowStats"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords": { + "/api/v1/workflows/{id}/graph": { parameters: { query?: never; header?: never; @@ -442,33 +490,37 @@ export interface paths { cookie?: never; }; /** - * List a mailbox's app passwords - * @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. + * Retrieve a workflow's step graph + * @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. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * A 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. + * + * The 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. + * + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listAppPasswords"]; - put?: never; + get: operations["v1GetWorkflowGraph"]; /** - * Create an app password - * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. + * Replace a workflow's step graph + * @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. * - * **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. + * A 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. * - * That 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. + * Refused 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. * - * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * The 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createAppPassword"]; + put: operations["v1ReplaceWorkflowGraph"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords/{passwordId}": { + "/api/v1/workflows/{id}/clone": { parameters: { query?: never; header?: never; @@ -477,52 +529,48 @@ export interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an app password - * @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. + * Clone a workflow + * @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. * - * Requires an admin of the project. An API key is refused with 401. + * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeAppPassword"]; + post: operations["v1CloneWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys": { + "/api/v1/workflows/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List API keys for a project - * @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. - * - * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. - */ - get: operations["listApiKeys"]; + get?: never; put?: never; /** - * Create an API key - * @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. + * Pause a workflow and cancel its running executions + * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. * - * **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. + * `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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createApiKey"]; + post: operations["v1PauseWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}": { + "/api/v1/workflows/{id}/resume": { parameters: { query?: never; header?: never; @@ -531,20 +579,22 @@ export interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an API key - * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * Resume a paused workflow + * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Refused 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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeApiKey"]; + post: operations["v1ResumeWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}/rotate": { + "/api/v1/emails": { parameters: { query?: never; header?: never; @@ -554,47 +604,55 @@ export interface paths { get?: never; put?: never; /** - * Rotate an API key's secret - * @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`). + * Send a transactional email + * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * This 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. + * + * Exactly 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. + * + * `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. + * + * An 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - post: operations["rotateApiKey"]; + post: operations["v1SendEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression": { + "/api/v1/emails/test": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List suppressed emails - * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. - * - * Requires the `suppression:read` scope — View the addresses on your suppression list. - */ - get: operations["listSuppressions"]; + get?: never; put?: never; /** - * Manually add an email to the suppression list - * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * Send a sandbox test email + * @description Prove that sending works — before any domain, DNS record or verification exists. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * The 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. + * + * That 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. + * + * Sandbox 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. + * + * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. */ - post: operations["addSuppression"]; + post: operations["v1SendTestEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression/{email}": { + "/api/emails": { parameters: { query?: never; header?: never; @@ -602,27 +660,27 @@ export interface paths { cookie?: never; }; /** - * Check whether an email is suppressed - * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * List emails + * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["checkSuppression"]; + get: operations["listEmails"]; put?: never; - post?: never; /** - * Remove an email from the suppression list - * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * Send a single transactional email + * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["removeSuppression"]; + post: operations["sendEmail"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates": { + "/api/emails/{id}": { parameters: { query?: never; header?: never; @@ -630,59 +688,47 @@ export interface paths { cookie?: never; }; /** - * List templates - * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. + * Get a single email + * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. * - * Requires the `templates:read` scope — View your email templates. - */ - get: operations["listTemplates"]; - put?: never; - /** - * Create a template - * @description Create a new email template. The `from` domain must already be verified for the project. + * `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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - post: operations["createTemplate"]; + get: operations["getEmail"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates/{id}": { + "/api/emails/batch": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a template - * @description Requires the `templates:read` scope — View your email templates. - */ - get: operations["getTemplate"]; + get?: never; put?: never; - post?: never; /** - * Delete a template - * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * Send a batch of emails + * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * The 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["deleteTemplate"]; + post: operations["sendEmailBatch"]; + delete?: never; options?: never; head?: never; - /** - * Update a template - * @description Update one or more fields. If `from` changes, the new domain must already be verified. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateTemplate"]; + patch?: never; trace?: never; }; - "/api/track": { + "/api/emails/{id}/schedule": { parameters: { query?: never; header?: never; @@ -691,94 +737,96 @@ export interface paths { }; get?: never; put?: never; + post?: never; /** - * Track a custom event for a contact - * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. - * - * Requires the `events:write` scope — Record custom events for your contacts. + * Cancel a scheduled (still-PENDING) email + * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. */ - post: operations["trackEvent"]; - delete?: never; + delete: operations["cancelScheduledEmail"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/users/me/projects": { + "/api/contacts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create a project - * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * List contacts + * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. * - * Preconditions 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. + * Requires the `contacts:read` scope — View your contacts and their custom fields. + */ + get: operations["listContacts"]; + put?: never; + /** + * Create a contact + * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. * - * Requires the `projects:write` scope — Create new projects on your account. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["createProject"]; + post: operations["createContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/campaigns": { + "/api/contacts/upsert": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve campaign totals and engagement - * @description Campaign counts plus average open and click rates. - * - * `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. - * - * `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. + * Create or update a contact by email + * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetCampaignAnalytics"]; - put?: never; - post?: never; + post: operations["upsertContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/timeseries": { + "/api/contacts/bulk": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve the daily email time series - * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * Bulk-create contacts + * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. * - * `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. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + post: operations["bulkCreateContacts"]; + /** + * Bulk-delete contacts + * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetAnalyticsTimeseries"]; - put?: never; - post?: never; - delete?: never; + delete: operations["bulkDeleteContacts"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/top-campaigns": { + "/api/contacts/{id}": { parameters: { query?: never; header?: never; @@ -786,23 +834,31 @@ export interface paths { cookie?: never; }; /** - * List the best-performing campaigns - * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. - * - * `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. - * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Get a contact + * @description Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListTopCampaigns"]; + get: operations["getContact"]; put?: never; post?: never; - delete?: never; + /** + * Delete a contact + * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + delete: operations["deleteContact"]; options?: never; head?: never; - patch?: never; + /** + * Update a contact + * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + patch: operations["updateContact"]; trace?: never; }; - "/api/v1/campaigns": { + "/api/v1/contacts": { parameters: { query?: never; header?: never; @@ -810,33 +866,29 @@ export interface paths { cookie?: never; }; /** - * List campaigns - * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * List contacts + * @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. * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * A 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. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListCampaigns"]; + get: operations["v1ListContacts"]; put?: never; /** - * Create a campaign - * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. - * - * The `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. - * - * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * Create a contact + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateCampaign"]; + post: operations["v1CreateContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}": { + "/api/v1/contacts/{id}": { parameters: { query?: never; header?: never; @@ -844,35 +896,37 @@ export interface paths { cookie?: never; }; /** - * Retrieve a campaign - * @description Fetch one campaign, including its materialized delivery counters. + * Retrieve a contact + * @description Fetch one contact by id. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1GetCampaign"]; + get: operations["v1GetContact"]; put?: never; post?: never; /** - * Delete a campaign - * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. + * Delete a contact + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteCampaign"]; + delete: operations["v1DeleteContact"]; options?: never; head?: never; /** - * Update a campaign - * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * Update a contact + * @description Partial update. Omitted fields are left alone. * - * Changing `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. + * `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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * `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. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - patch: operations["v1UpdateCampaign"]; + patch: operations["v1UpdateContact"]; trace?: never; }; - "/api/v1/campaigns/{id}/cancel": { + "/api/lists/{id}/subscribe": { parameters: { query?: never; header?: never; @@ -882,21 +936,23 @@ export interface paths { get?: never; put?: never; /** - * Cancel a campaign - * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. + * Subscribe a contact to a list + * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. * - * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * **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. + * + * `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. */ - post: operations["v1CancelCampaign"]; + post: operations["subscribeToList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/pause": { + "/api/lists/{id}/unsubscribe": { parameters: { query?: never; header?: never; @@ -906,67 +962,53 @@ export interface paths { get?: never; put?: never; /** - * Pause a sending campaign - * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. + * Unsubscribe a contact from a list + * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. */ - post: operations["v1PauseCampaign"]; + post: operations["unsubscribeFromList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/resume": { + "/api/v1/lists": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Resume a paused campaign - * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. + * List subscriber lists + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * `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. + * + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - post: operations["v1ResumeCampaign"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/campaigns/{id}/send": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; + get: operations["v1ListLists"]; put?: never; /** - * Send or schedule a campaign - * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. + * Create a subscriber list + * @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`. * - * **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. + * **`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. * - * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. * - * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["v1SendCampaign"]; + post: operations["v1CreateList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/stats": { + "/api/v1/lists/{id}": { parameters: { query?: never; header?: never; @@ -974,81 +1016,101 @@ export interface paths { cookie?: never; }; /** - * Retrieve campaign statistics - * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. - * - * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. + * Retrieve a subscriber list + * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - get: operations["v1GetCampaignStats"]; + get: operations["v1GetList"]; put?: never; post?: never; - delete?: never; + /** + * Delete a subscriber list + * @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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + delete: operations["v1DeleteList"]; options?: never; head?: never; - patch?: never; + /** + * Update a subscriber list + * @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. + * + * **`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. + * + * Turning `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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + patch: operations["v1UpdateList"]; trace?: never; }; - "/api/v1/emails": { + "/api/domains": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a transactional email - * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. - * - * This 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. - * - * Exactly 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. - * - * `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. + * List sending domains + * @description List all domains for the authenticated project. * - * An 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. + * Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["listDomains"]; + put?: never; + /** + * Add a sending domain + * @description Register a new domain with SES and persist its DKIM tokens. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1SendEmail"]; + post: operations["addDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails/test": { + "/api/domains/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a sandbox test email - * @description Prove that sending works — before any domain, DNS record or verification exists. + * Get a sending domain + * @description Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["getDomain"]; + put?: never; + post?: never; + /** + * Remove a sending domain + * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. * - * The 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. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + delete: operations["deleteDomain"]; + options?: never; + head?: never; + /** + * Assign a sending identity to a stream + * @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. * - * That 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. + * Streams 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. * - * Sandbox 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. + * At 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. * - * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1SendTestEmail"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["assignDomainStream"]; trace?: never; }; - "/api/v1/events": { + "/api/domains/{id}/verify": { parameters: { query?: never; header?: never; @@ -1056,59 +1118,53 @@ export interface paths { cookie?: never; }; /** - * List events - * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. - * - * A 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. + * Read SES verification status + * @description Read the current SES verification status without forcing a refresh. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1ListEvents"]; + get: operations["getDomainVerification"]; put?: never; /** - * Record an event - * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. - * - * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. - * - * Reserved 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. - * - * This 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. - * - * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * Trigger SES verification + * @description Force a refresh of the domain's SES verification status. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1TrackEvent"]; + post: operations["verifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events/names": { + "/api/domains/{id}/dodomain-session": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List event names - * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * Start guided DNS setup + * @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. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * The 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. + * + * `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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1ListEventNames"]; - put?: never; - post?: never; + post: operations["startDomainSetup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events/stats": { + "/api/v1/domains": { parameters: { query?: never; header?: never; @@ -1116,23 +1172,35 @@ export interface paths { cookie?: never; }; /** - * Retrieve event counts - * @description Per-name event counts over a bounded window, most frequent first. + * List sending domains + * @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. * - * The 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. + * `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. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetEventStats"]; + get: operations["v1ListDomains"]; put?: never; - post?: never; + /** + * Add a sending domain + * @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. + * + * `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. + * + * `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. + * + * A 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["v1CreateDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/projects": { + "/api/v1/domains/{id}": { parameters: { query?: never; header?: never; @@ -1140,57 +1208,55 @@ export interface paths { cookie?: never; }; /** - * Retrieve the authenticated project - * @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. - * - * `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). - * - * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * Retrieve a sending domain + * @description Fetch one sending domain by id. * - * Requires the `projects:read` scope — View your projects and their settings. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetProject"]; + get: operations["v1GetDomain"]; put?: never; post?: never; - delete?: never; + /** + * Remove a sending domain + * @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. + * + * The 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + delete: operations["v1DeleteDomain"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments": { + "/api/v1/domains/{id}/verify": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List segments - * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * `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. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. - */ - get: operations["v1ListSegments"]; + get?: never; put?: never; /** - * Create a segment - * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. + * Refresh a sending domain's verification state + * @description Re-read this domain's state from SES and DNS and return the refreshed document. * - * A `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. + * This 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. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1CreateSegment"]; + post: operations["v1VerifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments/{id}": { + "/api/templates": { parameters: { query?: never; header?: never; @@ -1198,35 +1264,27 @@ export interface paths { cookie?: never; }; /** - * Retrieve a segment - * @description Fetch one segment, including its saved `condition` and materialized `member_count`. + * List templates + * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetSegment"]; + get: operations["listTemplates"]; put?: never; - post?: never; /** - * Delete a segment - * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. + * Create a template + * @description Create a new email template. The `from` domain must already be verified for the project. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["v1DeleteSegment"]; + post: operations["createTemplate"]; + delete?: never; options?: never; head?: never; - /** - * Update a segment - * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. - * - * `type` is not accepted here — see the create operation. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. - */ - patch: operations["v1UpdateSegment"]; + patch?: never; trace?: never; }; - "/api/v1/segments/{id}/contacts": { + "/api/templates/{id}": { parameters: { query?: never; header?: never; @@ -1234,23 +1292,31 @@ export interface paths { cookie?: never; }; /** - * List the contacts in a segment - * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. - * - * Cursors 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. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Get a template + * @description Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListSegmentContacts"]; + get: operations["getTemplate"]; put?: never; post?: never; - delete?: never; + /** + * Delete a template + * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + delete: operations["deleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Update one or more fields. If `from` changes, the new domain must already be verified. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateTemplate"]; trace?: never; }; - "/api/v1/usage": { + "/api/v1/templates": { parameters: { query?: never; header?: never; @@ -1258,28 +1324,31 @@ export interface paths { cookie?: never; }; /** - * Retrieve current usage and limits - * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. - * - * Every 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. + * List templates + * @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. * - * Two caveats worth reading before you alert on these numbers: + * `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. * - * - 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. - * - `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`. + * A 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. * - * Requires the `usage:read` scope — View your usage totals and billing limits. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetUsage"]; + get: operations["v1ListTemplates"]; put?: never; - post?: never; + /** + * Create a template + * @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. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + post: operations["v1CreateTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows": { + "/api/v1/templates/{id}": { parameters: { query?: never; header?: never; @@ -1287,51 +1356,65 @@ export interface paths { cookie?: never; }; /** - * List workflows - * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * Retrieve a template + * @description Fetch one template by id. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListWorkflows"]; + get: operations["v1GetTemplate"]; put?: never; + post?: never; /** - * Create a workflow - * @description Creates an event-triggered workflow with a single trigger step. The rest of the graph (emails, delays, conditions) is built in the dashboard, so a workflow is created disabled and stays inert until it has steps to run. + * Delete a template + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1CreateWorkflow"]; - delete?: never; + delete: operations["v1DeleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Partial update. Omitted fields are left alone. + * + * Changing `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. + * + * A `from` supplied here is verified before anything is written, on the same terms as create. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["v1UpdateTemplate"]; trace?: never; }; - "/api/v1/workflows/executions/{execution_id}/cancel": { + "/api/snippets": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List snippets + * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. + * + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listSnippets"]; put?: never; /** - * Cancel a workflow execution - * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. + * Create a snippet + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1CancelWorkflowExecution"]; + post: operations["createSnippet"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}": { + "/api/snippets/{id}": { parameters: { query?: never; header?: never; @@ -1339,35 +1422,29 @@ export interface paths { cookie?: never; }; /** - * Retrieve a workflow - * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Get a snippet + * @description Requires the `templates:read` scope — View your email templates. */ - get: operations["v1GetWorkflow"]; + get: operations["getSnippet"]; put?: never; post?: never; /** - * Delete a workflow - * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. + * Delete a snippet + * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["v1DeleteWorkflow"]; + delete: operations["deleteSnippet"]; options?: never; head?: never; /** - * Update a workflow - * @description Sparse update — omitted fields are left unchanged. - * - * Two 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. - * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Update a snippet + * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - patch: operations["v1UpdateWorkflow"]; + patch: operations["updateSnippet"]; trace?: never; }; - "/api/v1/workflows/{id}/executions": { + "/api/webhooks": { parameters: { query?: never; header?: never; @@ -1375,29 +1452,27 @@ export interface paths { cookie?: never; }; /** - * List a workflow's executions - * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * List user webhooks + * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1ListWorkflowExecutions"]; + get: operations["listWebhooks"]; put?: never; /** - * Start a workflow for a contact - * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. - * - * 409 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. + * Create a webhook + * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1StartWorkflowExecution"]; + post: operations["createWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/stats": { + "/api/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -1405,21 +1480,29 @@ export interface paths { cookie?: never; }; /** - * Retrieve workflow statistics - * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. - * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Get a webhook + * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetWorkflowStats"]; + get: operations["getWebhook"]; put?: never; post?: never; - delete?: never; + /** + * Delete a webhook + * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + delete: operations["deleteWebhook"]; options?: never; head?: never; - patch?: never; + /** + * Update a webhook + * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + patch: operations["updateWebhook"]; trace?: never; }; - "/api/verify": { + "/api/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; @@ -1429,17 +1512,19 @@ export interface paths { get?: never; put?: never; /** - * Validate an email address - * @description Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier. + * Rotate the webhook signing secret + * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["verifyEmailAddress"]; + post: operations["rotateWebhookSecret"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks": { + "/api/webhooks/{id}/calls": { parameters: { query?: never; header?: never; @@ -1447,27 +1532,53 @@ export interface paths { cookie?: never; }; /** - * List user webhooks - * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). + * List recent webhook calls + * @description Cursor-paginated list of recent delivery attempts for a single webhook. * * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["listWebhooks"]; + get: operations["listWebhookCalls"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List webhooks + * @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. + * + * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + */ + get: operations["v1ListWebhooks"]; put?: never; /** * Create a webhook - * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. + * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. + * + * The 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. * * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["createWebhook"]; + post: operations["v1CreateWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}": { + "/api/v1/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -1475,51 +1586,93 @@ export interface paths { cookie?: never; }; /** - * Get a webhook - * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Retrieve a webhook + * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. + * + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["getWebhook"]; + get: operations["v1GetWebhook"]; put?: never; post?: never; /** * Delete a webhook - * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * @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. * * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - delete: operations["deleteWebhook"]; + delete: operations["v1DeleteWebhook"]; options?: never; head?: never; /** * Update a webhook - * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * @description Partial update. 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 part of this response. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - patch: operations["updateWebhook"]; + patch: operations["v1UpdateWebhook"]; trace?: never; }; - "/api/webhooks/{id}/calls": { + "/api/v1/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List recent webhook calls - * @description Cursor-paginated list of recent delivery attempts for a single webhook. + * Rotate a webhook signing secret + * @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. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Rotation 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. + * + * `url`, `event_types` and `status` are unchanged. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["listWebhookCalls"]; + post: operations["v1RotateWebhookSecret"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{id}/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List API keys for a project + * @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. + * + * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + */ + get: operations["listApiKeys"]; put?: never; - post?: never; + /** + * Create an API key + * @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. + * + * **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. + * + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + */ + post: operations["createApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}/rotate-secret": { + "/api/projects/{id}/api-keys/{keyId}/rotate": { parameters: { query?: never; header?: never; @@ -1529,1710 +1682,8445 @@ export interface paths { get?: never; put?: never; /** - * Rotate the webhook signing secret - * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * Rotate an API key's secret + * @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`). * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["rotateWebhookSecret"]; + post: operations["rotateApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; -} -export type webhooks = Record; -export interface components { - schemas: { - /** @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. */ - AddDomainBody: { - domain: string; - /** Format: uuid */ - projectId?: string; - /** - * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. - * @enum {string} - */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; - /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ - AddSuppression: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + "/api/projects/{id}/api-keys/{keyId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Campaign counters and engagement over the window. */ - AnalyticsCampaignStatsV1: { - /** @description Campaigns in DRAFT or SCHEDULED. */ - active: number; - average_click_rate: number; - /** @description Percentage, one decimal place. */ - average_open_rate: number; - completed: number; - total: number; - window: components["schemas"]["AnalyticsWindowV1"]; + get?: never; + put?: never; + post?: never; + /** + * Revoke an API key + * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + */ + delete: operations["revokeApiKey"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/suppression": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ - AnalyticsTimeseriesV1: { - data: { - bounces: number; - clicks: number; - /** Format: date-time */ - date: string; - delivered: number; - emails: number; - opens: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** + * List suppressed emails + * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["listSuppressions"]; + put?: never; + /** + * Manually add an email to the suppression list + * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + post: operations["addSuppression"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/suppression/{email}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Sent campaigns ranked by open rate. */ - AnalyticsTopCampaignsV1: { - data: { - click_rate: number; - clicked: number; - /** Format: uuid */ - id: string; - open_rate: number; - opened: number; - sent: number; - subject: string; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** + * Check whether an email is suppressed + * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["checkSuppression"]; + put?: never; + post?: never; + /** + * Remove an email from the suppression list + * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["removeSuppression"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description The time range this response was computed over, after the 90-day clamp. */ - AnalyticsWindowV1: { - /** Format: date-time */ - from: string; - /** Format: date-time */ - to: string; + /** + * List suppressed addresses + * @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. + * + * A 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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1ListSuppressions"]; + put?: never; + /** + * Suppress an address + * @description Add an address to this project's suppression list, so no further send reaches it. + * + * Idempotent: 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. + * + * `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. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + post: operations["v1CreateSuppression"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions/{email}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @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. */ - ApiKey: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** Format: uuid */ - domainId: string | null; - /** Format: uuid */ - id: string; - /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ - lastFour: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - lastUsedAt: string | null; - name: string; - /** @enum {string} */ - permission: "FULL" | "SENDING_ONLY"; - /** Format: uuid */ - projectId: string; - /** - * Format: date-time - * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. - */ - revokedAt: string | null; - /** @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. */ - scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test")[]; + /** + * Check whether an address is suppressed + * @description Fetch the suppression record for one address. The path parameter is the address itself, URL-encoded. + * + * An 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. + * + * A `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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1GetSuppression"]; + put?: never; + post?: never; + /** + * Remove an address from the suppression list + * @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. + * + * It 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. + * + * Idempotent: an address that was never suppressed answers `200` too, because "not on the list" is the state you asked for. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["v1DeleteSuppression"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/track": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ - ApiKeyListResponse: { - data: components["schemas"]["ApiKey"][]; - /** @enum {boolean} */ - success: true; + get?: never; + put?: never; + /** + * Track a custom event for a contact + * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["trackEvent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ - AppPassword: { - /** Format: date-time */ - createdAt: string; - /** Format: uuid */ - id: string; - /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ - lastFour: string; - /** - * Format: date-time - * @description Null until a mail client has authenticated with it at least once. - */ - lastUsedAt: string | null; - /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ - name: string; - /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ - scopes: ("imap" | "smtp")[]; + /** + * List events + * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. + * + * A 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1ListEvents"]; + put?: never; + /** + * Record an event + * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. + * + * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * + * Reserved 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. + * + * This 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. + * + * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["v1TrackEvent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events/names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ - AppPasswordReveal: { - /** Format: uuid */ - id: string; - /** - * Format: date-time - * @description When the link stops working. Five minutes after creation; the password itself does not expire. - */ - revealExpiresAt: string; - /** - * Format: uri - * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. - */ - revealUrl: string; + /** + * List event names + * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1ListEventNames"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Per-row result in a batch send response. */ - BatchEntryResult: { - data?: components["schemas"]["SendEmailData"]; - error?: { - code: string; - message: string; - }; - index: number; - /** @enum {string} */ - status: "ok" | "error"; + /** + * Retrieve event counts + * @description Per-name event counts over a bounded window, most frequent first. + * + * The 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. + */ + get: operations["v1GetEventStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/timeseries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Batch send wrapper. Up to 100 entries. */ - BatchSendBody: { - emails: components["schemas"]["SendEmail"][]; - }; - /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ - BatchSendResponse: { - data: components["schemas"]["BatchEntryResult"][]; - success: boolean; + /** + * Retrieve the daily email time series + * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1GetAnalyticsTimeseries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description A campaign as exposed on the v1 API. */ - CampaignV1: { - /** @enum {string} */ - audience_type: "ALL" | "FILTERED" | "SEGMENT"; - /** Format: date-time */ - created_at: string; - /** Format: uuid */ - id: string; - name: string; - /** Format: date-time */ - scheduled_at: string | null; - /** Format: date-time */ - sent_at: string | null; - stats: { - bounced: number; - clicked: number; - delivered: number; - opened: number; - sent: number; - total_recipients: number; - }; - /** @enum {string} */ - status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; - subject: string; + /** + * Retrieve campaign totals and engagement + * @description Campaign counts plus average open and click rates. + * + * `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. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1GetCampaignAnalytics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analytics/top-campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @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`. */ - CampaignV1Create: { - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** - * @description `ALL` — every subscribed contact. `FILTERED` — the contacts matching `audience_condition`. `SEGMENT` — the members of `segment_id`. - * @enum {string} - */ - audience_type: "ALL" | "FILTERED" | "SEGMENT"; - body: string; - description?: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from: string; - from_name?: string | null; - name: string; - /** Format: email */ - reply_to?: string | null; - /** Format: uuid */ - segment_id?: string; - subject: string; - /** - * @default MARKETING - * @enum {string} - */ - type: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + /** + * List the best-performing campaigns + * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + */ + get: operations["v1ListTopCampaigns"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/diagnose": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Acknowledgement that a campaign was deleted. */ - CampaignV1Deleted: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; + /** + * Diagnose why mail from a domain is not arriving + * @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. + * + * Everything 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. + * + * `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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1DiagnoseDeliverability"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Cursor-paginated list of campaigns. */ - CampaignV1List: { - data: components["schemas"]["CampaignV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** + * Delivery outcomes per recipient domain + * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. + * + * This 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. + * + * The 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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1ListRecipientDomainStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deliverability/dmarc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/v1/campaigns/{id}/send. */ - CampaignV1Send: { - /** - * Format: date-time - * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. - */ - scheduled_for?: string; + /** + * DMARC aggregate reports for your domains + * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * + * The 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. + * + * `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. + * + * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + */ + get: operations["v1ListDmarcReports"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Materialized delivery and engagement counters for one campaign. */ - CampaignV1Stats: { - bounce_rate: number; - bounced: number; - click_rate: number; - clicked: number; - delivered: number; - delivery_rate: number; - open_rate: number; - opened: number; - sent: number; - total_recipients: number; + /** + * Retrieve current usage and limits + * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. + * + * Every 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. + * + * Two caveats worth reading before you alert on these numbers: + * + * - 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. + * - `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`. + * + * Requires the `usage:read` scope — View your usage totals and billing limits. + */ + get: operations["v1GetUsage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ - CampaignV1Update: { - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** @enum {string} */ - audience_type?: "ALL" | "FILTERED" | "SEGMENT"; - body?: string; - description?: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from?: string; - from_name?: string | null; - name?: string; - /** Format: email */ - reply_to?: string | null; - /** Format: uuid */ - segment_id?: string; - subject?: string; - /** @enum {string} */ - type?: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; + /** + * Retrieve the authenticated project + * @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. + * + * `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). + * + * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * + * Requires the `projects:read` scope — View your projects and their settings. + */ + get: operations["v1GetProject"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description A subscriber/contact within a project. */ - Contact: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - customFields?: { - [key: string]: unknown; - } | null; - /** Format: email */ - email: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - subscribed: boolean; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + /** + * List mailboxes + * @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. + * + * This 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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["listMailboxes"]; + put?: never; + /** + * Create a mailbox + * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. + * + * Three consequences worth knowing before you call it: + * + * - **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. + * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. + * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * + * Retrying 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. + * + * `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. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + post: operations["createMailbox"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Bulk create up to 1000 contacts. */ - ContactBulkCreateBody: { - contacts: components["schemas"]["CreateContact"][]; + /** + * Get a mailbox + * @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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["getMailbox"]; + put?: never; + post?: never; + /** + * Delete a mailbox + * @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. + * + * Requires an admin of the project. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + delete: operations["deleteMailbox"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ - ContactBulkDeleteBody: { - emails?: string[]; - ids?: string[]; + get?: never; + put?: never; + /** + * Draft a message with AI + * @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. + * + * **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. + * + * That 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. + * + * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * + * Drafting is capped at 120 requests per hour per project. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + post: operations["draftMailboxMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Cursor-paginated list of contacts. */ - ContactListResponse: { - data: { - data: components["schemas"]["Contact"][]; - hasMore: boolean; - /** @description Cursor for the next page, or null on the last page. */ - nextCursor: string | null; - total: number; - }; - /** @enum {boolean} */ - success: true; + get?: never; + put?: never; + /** + * Send a message from a mailbox + * @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. + * + * **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. + * + * **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. + * + * Bcc 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. + * + * Refusals worth handling by name: + * + * - `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. + * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. + * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. + * - `429` — a mailbox may send 60 messages an hour through this endpoint. + * + * The message is stored as a new conversation on the mailbox, so the reply threads onto it. + * + * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. + */ + post: operations["sendMailboxMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/app-passwords": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - CreateApiKeyBody: { - /** Format: uuid */ - domainId?: string | null; - name: string; - /** @enum {string} */ - permission?: "FULL" | "SENDING_ONLY"; - /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `permission`. A `SENDING_ONLY` key may carry only `emails:send`. */ - scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test")[]; + /** + * List a mailbox's app passwords + * @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. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + */ + get: operations["listAppPasswords"]; + put?: never; + /** + * Create an app password + * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. + * + * **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. + * + * That 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. + * + * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + post: operations["createAppPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mailboxes/{id}/app-passwords/{passwordId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/mailboxes/:id/app-passwords. */ - CreateAppPassword: { - name: string; - /** - * @default [ - * "imap", - * "smtp" - * ] - */ - scopes: ("imap" | "smtp")[]; + get?: never; + put?: never; + post?: never; + /** + * Revoke an app password + * @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. + * + * Requires an admin of the project. An API key is refused with 401. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + */ + delete: operations["revokeAppPassword"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/contacts and /api/contacts/upsert. */ - CreateContact: { - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - customFields?: { - [key: string]: unknown; - }; - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; - }; - CreateMailboxBody: { - displayName?: string; - /** - * Format: uuid - * @description A VERIFIED domain belonging to this project. - */ - domainId: string; - /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ - localPart: string; - /** - * Format: uuid - * @description Defaults to the project the credential resolves to. Naming a different one is refused. - */ - projectId?: string; - /** @description NOT IMPLEMENTED — sending any value answers 400. */ - quotaBytes?: number; - }; - /** @description Body for POST /api/templates. */ - CreateTemplate: { - body: string; - description?: string; - /** Format: email */ - from: string; - fromName?: string | null; - name: string; - /** Format: email */ - replyTo?: string | null; - subject: string; - /** - * @default MARKETING - * @enum {string} - */ - type: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; - }; - /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ - CreateWebhook: { - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** Format: uri */ - url: string; + get?: never; + put?: never; + /** + * Create a project + * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * + * Preconditions 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. + * + * Requires the `projects:write` scope — Create new projects on your account. + */ + post: operations["createProject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description A sending domain registered with SES. */ - Domain: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - dkim?: { - name: string; - type: string; - value: string; - }[]; - /** Format: uuid */ - id: string; - /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - name: string; - /** Format: uuid */ - projectId: string; - region?: string | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - verified: boolean; + get?: never; + put?: never; + /** + * Validate an email address + * @description Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier. + */ + post: operations["verifyEmailAddress"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description List of all domains for the auth'd project. */ - DomainListResponse: { - data: components["schemas"]["Domain"][]; - /** @enum {boolean} */ - success: true; + /** + * List topics + * @description The subjects this project mails about, cursor-paginated and newest first. + * + * Archived 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. + * + * `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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1ListTopics"]; + put?: never; + /** + * Create a topic + * @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. + * + * `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`. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + post: operations["v1CreateTopic"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/topics/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Outcome of a verification check against SES. */ - DomainVerificationStatus: { - dkim?: { - name: string; - type: string; - value: string; - }[]; - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - mxRecords?: string[]; - verified: boolean; + /** + * Retrieve a topic + * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1GetTopic"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update a topic + * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + patch: operations["v1UpdateTopic"]; + trace?: never; + }; + "/api/v1/topics/{id}/subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description A sent (or queued) transactional email. */ - Email: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - error?: string | null; - from: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - status: "PENDING" | "SENT" | "DELIVERED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED"; - subject: string; - tags: string[]; - to: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + get?: never; + put?: never; + /** + * Subscribe or unsubscribe a contact from a topic + * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. + * + * `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. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + post: operations["v1SetTopicSubscription"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/contacts/{id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Single email with its events. */ - EmailGetResponse: { - data: components["schemas"]["Email"]; - /** @enum {boolean} */ - success: true; + /** + * Get a contact's topic preferences + * @description Everything this contact has said they want, as the send path reads it. + * + * `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. + * + * The 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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + */ + get: operations["v1GetContactTopicPreferences"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/email-validations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Cursor-paginated list of emails. */ - EmailListResponse: { - data: components["schemas"]["Email"][]; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; + get?: never; + put?: never; + /** + * Validate a batch of email addresses + * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. + * + * The 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. + * + * `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. + * + * The 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + */ + post: operations["v1ValidateEmails"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/lists/{id}/validation-runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Receipt for a sandbox test send. */ - EmailTestV1: { + get?: never; + put?: never; + /** + * Validate every address on a list + * @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. + * + * This 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`. + * + * A 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + */ + post: operations["v1StartListValidationRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/validation-runs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve a validation run + * @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. + * + * There 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. + * + * Requires the `validation:read` scope — View your email validation runs and their results. + */ + get: operations["v1GetValidationRun"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/validation-runs/{id}/results": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a validation run's results + * @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. + * + * No 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. + * + * `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. + * + * Requires the `validation:read` scope — View your email validation runs and their results. + */ + get: operations["v1ListValidationRunResults"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ + Error: { + /** @enum {boolean} */ + success?: false; + error: { + message: string; + code: string; + details?: { + errors: unknown[]; + }; + }; + }; + /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ + Problem: { /** - * Format: email - * @description This project's sandbox sender — resolved server-side, never from the body. + * Format: uri + * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + */ + type: string; + /** @description Short, stable summary — the same for every occurrence of a `type`. */ + title: string; + /** @description HTTP status code, repeated in the body. */ + status: number; + /** @description Explanation specific to this occurrence. */ + detail?: string; + /** @description Request path the failure occurred on. */ + instance?: string; + /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ + code: string; + /** @description Correlation id — quote it in support requests. */ + request_id?: string; + /** @description Field-level failures. Present on 422 `validation_error` responses. */ + errors?: { + /** @description RFC 6901 JSON Pointer to the offending field. */ + pointer: string; + code: string; + message: string; + }[]; + }; + /** @description Bare success envelope with no payload. */ + SuccessEmpty: { + /** @enum {boolean} */ + success: true; + }; + /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ + IdResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + id: string; + }; + }; + /** @description A subscriber/contact within a project. */ + Contact: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: email */ + email: string; + subscribed: boolean; + customFields?: { + [key: string]: unknown; + } | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string */ + updatedAt: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Contact"][]; + total: number; + /** @description Cursor for the next page, or null on the last page. */ + nextCursor: string | null; + hasMore: boolean; + }; + }; + /** @description A reusable email template. */ + Template: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + description?: string | null; + subject: string; + body: string; + /** Format: email */ from: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** @enum {string} */ + emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; /** - * Format: uuid - * @description The Email row this send created. + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string */ + updatedAt: string; + }; + /** @description Cursor-paginated list of templates. */ + TemplateListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Template"][]; + total: number; + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + hasMore: boolean; + }; + }; + /** @description A reusable fragment of template markup. */ + Snippet: { + /** Format: uuid */ id: string; + /** Format: uuid */ + projectId: string; + /** @description The literal identifier a template includes with `{{> name}}`. */ + name: string; + description?: string | null; + /** @description Template markup. Values it interpolates are escaped like any other. */ + body: string; /** - * @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 {boolean} + * Format: date-time + * @description ISO 8601 datetime string */ - sandbox: true; + createdAt: string; /** - * @description Delivery status at the moment of the response — `PENDING` for a send still queued. - * @enum {string} + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of snippets. */ + SnippetListResponse: { + /** @enum {boolean} */ + success: true; + data: { + data: components["schemas"]["Snippet"][]; + total: number; + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + hasMore: boolean; + }; + }; + /** + * @description Delivery lifecycle of the message. Engagement is reported separately. + * @enum {string} + */ + EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ + Domain: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + verified: boolean; + region?: string | null; + dkim?: { + type: string; + name: string; + value: string; + }[]; + /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; /** - * Format: email - * @description The recipient the message was queued for. + * @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 {string|null} */ - to: string; + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @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). */ + streamDefault?: boolean; + /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ + defaultFromAddress?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description List of all domains for the auth'd project. */ + DomainListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Domain"][]; + }; + /** @description Outcome of a verification check against SES. */ + DomainVerificationStatus: { + verified: boolean; + mxRecords?: string[]; + dkim?: { + type: string; + name: string; + value: string; + }[]; + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + }; + /** @description A sent (or queued) transactional email. */ + Email: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + from: string; + to: string; + subject: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description First open, or null. + */ + openedAt: string | null; + /** + * Format: date-time + * @description First click, or null. + */ + clickedAt: string | null; + /** + * Format: date-time + * @description Spam complaint, or null. + */ + complainedAt: string | null; + /** @description Total opens recorded. */ + opens: number; + /** @description Total clicks recorded. */ + clicks: number; + tags: string[]; + error?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description One transition in a message's delivery history. */ + EmailEvent: { + /** Format: uuid */ + id: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description A transactional email together with its delivery history. */ + EmailWithEvents: components["schemas"]["Email"] & { + /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ + events: components["schemas"]["EmailEvent"][]; + }; + /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ + SendEmailRecipientResult: { + contact: { + /** Format: uuid */ + id: string; + /** Format: email */ + email: string; + }; + /** Format: uuid */ + email: string; + }; + /** @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`. */ + SendEmailData: { + emails: components["schemas"]["SendEmailRecipientResult"][]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @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. */ + SendEmailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["SendEmailData"]; + }; + /** @description Per-row result in a batch send response. */ + BatchEntryResult: { + index: number; + /** @enum {string} */ + status: "ok" | "error"; + data?: components["schemas"]["SendEmailData"]; + error?: { + message: string; + code: string; + }; + }; + /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ + BatchSendResponse: { + success: boolean; + data: components["schemas"]["BatchEntryResult"][]; + }; + /** @description Batch send wrapper. Up to 100 entries. */ + BatchSendBody: { + emails: components["schemas"]["SendEmail"][]; + }; + /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ + SendEmail: { + to: string | { + name?: string; + /** Format: email */ + email: string; + } | (string | { + name?: string; + /** Format: email */ + email: string; + })[]; + subject?: string; + body?: string; + /** Format: uuid */ + template?: string; + subscribed?: boolean; + name?: string; + from?: string | { + name?: string; + /** Format: email */ + email: string; + }; + /** Format: email */ + reply?: string; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + attachments?: { + filename: string; + content: string; + contentType: string; + contentId?: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + }[]; + tags?: string[]; + cc?: string[]; + bcc?: string[]; + }; + /** @description Bulk create up to 1000 contacts. */ + ContactBulkCreateBody: { + contacts: components["schemas"]["CreateContact"][]; + }; + /** @description Body for POST /api/contacts and /api/contacts/upsert. */ + CreateContact: { + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + customFields?: { + [key: string]: unknown; + }; + }; + /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ + ContactBulkDeleteBody: { + ids?: string[]; + emails?: string[]; + }; + /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ + UpdateContactBody: { + subscribed?: boolean; + customFields?: { + [key: string]: unknown; + }; + }; + /** @description A single suppressed-email record. */ + Suppression: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: email */ + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + }; + /** @description Cursor-paginated list of suppressions. */ + SuppressionListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Suppression"][]; + nextCursor?: string | null; + cursor?: string | null; + hasMore?: boolean; + }; + /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ + SuppressionCheckResponse: { + suppressed: boolean; + /** @enum {string} */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt?: string; + }; + /** @description A user-managed outbound webhook. */ + Webhook: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** Format: uri */ + url: string; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + consecutiveFailures: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + disabledAt?: string | null; + lastFour?: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ + WebhookCreateResponse: { + /** @enum {boolean} */ + success: true; + /** @description A user-managed outbound webhook. */ + data: components["schemas"]["Webhook"] & { + /** @description Plaintext shared secret. Returned ONCE on create. */ + secret: string; + }; + }; + /** @description List of webhooks for the auth'd project. */ + WebhookListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Webhook"][]; + }; + /** @description Single webhook (no secret). */ + WebhookGetResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Webhook"]; + }; + /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ + WebhookRotateSecretResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + id: string; + /** @description New plaintext shared secret. */ + secret: string; + }; + }; + /** @description An attempted webhook delivery. */ + WebhookCall: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + webhookId: string; + eventType: string; + payload: { + [key: string]: unknown; + }; + /** @enum {string} */ + status: "PENDING" | "SUCCESS" | "FAILED"; + attempt: number; + responseStatus?: number | null; + responseBody?: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + }; + /** @description Cursor-paginated list of recent calls for a single webhook. */ + WebhookCallsListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["WebhookCall"][]; + nextCursor?: string | null; + cursor?: string | null; + hasMore?: boolean; + }; + /** @description Response from POST /api/track. */ + TrackEventResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + contact: string; + /** Format: uuid */ + event: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + }; + /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ + VerifyEmailResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: email */ + email: string; + valid: boolean; + reason?: string; + } & { + [key: string]: unknown; + }; + }; + /** @description Cursor-paginated list of emails. */ + EmailListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Email"][]; + nextCursor?: string | null; + }; + /** @description A single email. */ + EmailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Email"]; + }; + /** @description One email and its delivery history. */ + EmailDetailResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["EmailWithEvents"]; + }; + /** @description Result of a list-subscribe call. */ + ListSubscribeResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: uuid */ + membershipId: string; + /** @enum {string} */ + status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; + /** @description True when the membership row did not exist before this call. */ + created: boolean; + /** + * @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 {string|null} + */ + previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; + /** @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. */ + confirmToken?: string; + }; + }; + /** @description Echoes the address that was unsubscribed. */ + ListUnsubscribeResponse: { + /** @enum {boolean} */ + success: true; + data: { + /** Format: email */ + email: string; + }; + }; + /** @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. */ + ApiKey: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + name: string; + /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ + lastFour: string; + /** + * @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 {string} + */ + legacyGrantPreset: "FULL" | "SENDING_ONLY"; + /** + * @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 {string} + */ + mode: "LIVE" | "TEST"; + /** @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. */ + scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + /** Format: uuid */ + domainId: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + lastUsedAt: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. + */ + revokedAt: string | null; + }; + /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ + ApiKeyListResponse: { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["ApiKey"][]; + }; + ProjectRecord: { + /** Format: uuid */ + id: string; + name: string; + disabled: boolean; + disabledReason: string | null; + /** @description Local-part of the sandbox quick-start sender; null until first derived. */ + sandboxHandle: string | null; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + billingLimitWorkflows: number | null; + billingLimitCampaigns: number | null; + billingLimitTransactional: number | null; + billingLimitInbound: number | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + sesRegion: string | null; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; + organizationId: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + }; + /** @description Cursor-paginated list of campaigns. */ + CampaignV1List: { + data: components["schemas"]["CampaignV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A campaign as exposed on the v1 API. */ + CampaignV1: { + /** Format: uuid */ + id: string; + name: string; + /** @enum {string} */ + status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; + subject: string; + /** @enum {string} */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + /** Format: uuid */ + list_id: string | null; + /** Format: uuid */ + topic_id: string | null; + /** Format: date-time */ + scheduled_at: string | null; + /** Format: date-time */ + sent_at: string | null; + /** Format: date-time */ + created_at: string; + stats: { + total_recipients: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + }; + }; + /** @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`. */ + CampaignV1Create: { + name: string; + description?: string; + subject: string; + body: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** + * @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 {string} + */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** Format: uuid */ + segment_id?: string; + /** + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + */ + list_id?: string; + /** + * Format: uuid + * @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. + */ + topic_id?: string | null; + }; + /** @description A filter condition: one or more groups combined with `logic`. */ + FilterConditionV1: { + /** @enum {string} */ + logic: "AND" | "OR"; + groups: components["schemas"]["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. */ + FilterGroupV1: { + filters: components["schemas"]["SegmentFilterV1"][]; + conditions?: components["schemas"]["FilterConditionV1"]; + }; + /** @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). */ + SegmentFilterV1: { + field: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; + value?: unknown; + /** @enum {string} */ + unit?: "days" | "hours" | "minutes"; + }; + /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ + CampaignV1Update: { + name?: string; + description?: string; + subject?: string; + body?: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from?: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** @enum {string} */ + audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** Format: uuid */ + segment_id?: string; + /** + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + */ + list_id?: string; + /** + * Format: uuid + * @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. + */ + topic_id?: string | null; + }; + /** @description Acknowledgement that a campaign was deleted. */ + CampaignV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/v1/campaigns/{id}/send. */ + CampaignV1Send: { + /** + * Format: date-time + * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. + */ + scheduled_for?: string; + }; + /** @description Materialized delivery and engagement counters for one campaign. */ + CampaignV1Stats: { + total_recipients: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + open_rate: number; + click_rate: number; + bounce_rate: number; + delivery_rate: number; + }; + /** @description Cursor-paginated list of a campaign's failed sends. */ + CampaignV1FailureList: { + data: components["schemas"]["CampaignV1Failure"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + /** @description Every FAILED row on this campaign, not just this page. */ + total: number; + }; + /** @description A campaign recipient whose send did not complete. */ + CampaignV1Failure: { + /** + * Format: uuid + * @description Ledger row id. Pass the last one as `after` to page. + */ + id: string; + /** Format: uuid */ + contact_id: string; + /** @description The recipient the send was for. */ + email: string; + reason: string | null; + /** Format: date-time */ + failed_at: string; + }; + /** @description Acknowledgement that a retry of a campaign's failed sends began. */ + CampaignV1RetryFailed: { + /** Format: uuid */ + id: string; + /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ + queued: number; + }; + /** @description Cursor-paginated list of segments. */ + SegmentV1List: { + data: components["schemas"]["SegmentV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A segment as exposed on the v1 API. */ + SegmentV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + /** @enum {string} */ + type: "DYNAMIC" | "STATIC"; + condition: components["schemas"]["FilterConditionV1"] | null; + track_membership: boolean; + member_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ + SegmentV1Create: { + name: string; + description?: string; + /** + * @default DYNAMIC + * @enum {string} + */ + type: "DYNAMIC" | "STATIC"; + condition?: components["schemas"]["FilterConditionV1"]; + /** + * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. + * @default false + */ + track_membership: boolean; + }; + /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ + SegmentV1Update: { + name?: string; + description?: string; + condition?: components["schemas"]["FilterConditionV1"]; + track_membership?: boolean; + }; + /** @description Acknowledgement that a segment was deleted. */ + SegmentV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Cursor-paginated list of the contacts belonging to a segment. */ + SegmentContactV1List: { + data: components["schemas"]["SegmentContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact belonging to a segment. */ + SegmentContactV1: { + /** Format: uuid */ + id: string; + email: string; + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: { + [key: string]: unknown; + }; + /** Format: date-time */ + created_at: string; + }; + /** @description Cursor-paginated list of workflows. */ + WorkflowV1List: { + data: components["schemas"]["WorkflowV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description An automation workflow as exposed on the v1 API. */ + WorkflowV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + enabled: boolean; + /** @enum {string} */ + trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ + event_name: string | null; + allow_reentry: boolean; + max_executions_per_hour: number | null; + /** @description Incremented on every structural (step/transition) change. */ + version: number; + /** @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. */ + step_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/workflows. */ + WorkflowCreateV1: { + name: string; + description?: string; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; + /** @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. */ + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ + enabled?: boolean; + allow_reentry?: boolean; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + }; + /** + * @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 {string} + */ + WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description One step of a linear workflow sequence. */ + WorkflowSequenceStepV1: { + type: components["schemas"]["WorkflowSequenceStepTypeV1"]; + /** @description Human-readable label, e.g. `Day 0: welcome`. */ + name: string; + /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @description For `SEND_EMAIL`: a template in this project. + */ + template_id?: string; + }; + /** + * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @enum {string} + */ + WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @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. */ + WorkflowUpdateV1: { + name?: string; + description?: string; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + enabled?: boolean; + allow_reentry?: boolean; + /** @description Per-workflow start rate cap. `null` removes the cap. */ + max_executions_per_hour?: number | null; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + }; + /** @description Confirmation that a workflow was deleted. */ + WorkflowDeletedV1: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Cursor-paginated list of workflow executions, newest first. */ + WorkflowExecutionV1List: { + data: components["schemas"]["WorkflowExecutionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One contact's run through a workflow. */ + WorkflowExecutionV1: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + workflow_id: string; + /** Format: uuid */ + contact_id: string; + /** @enum {string} */ + status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + /** Format: uuid */ + current_step_id: string | null; + exit_reason: string | null; + /** Format: date-time */ + started_at: string; + /** Format: date-time */ + completed_at: string | null; + }; + /** @description Body for POST /api/v1/workflows/{id}/executions. */ + WorkflowExecutionStartV1: { + /** + * Format: uuid + * @description Contact to enter the workflow. Must belong to this project. + */ + contact_id: string; + /** @description Extra variables merged into the contact's data for this run. */ + context?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Execution, email and conversion totals for one workflow. */ + WorkflowStatsV1: { + /** Format: uuid */ + workflow_id: string; + name: string; + enabled: boolean; + trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + /** @description Steps in the workflow's graph, trigger step included. */ + step_count: number; + total: number; + /** @description Execution counts keyed by status; a status with no executions is absent. */ + by_status: { + [key: string]: number; + }; + /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ + completion_rate: number | null; + avg_duration_ms: number | null; + emails: { + sent: number; + opened: number; + clicked: number; + }; + conversions: { + /** Format: uuid */ + goal_id: string; + name: string; + event_name: string; + count: number; + }[]; + }; + /** @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. */ + WorkflowGraphV1: { + /** Format: uuid */ + workflow_id: string; + /** @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. */ + version: number; + steps: components["schemas"]["WorkflowStepReadV1"][]; + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @description One node of a workflow graph, as read. */ + WorkflowStepReadV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** @enum {string} */ + type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Where this step sits on the editor canvas. */ + WorkflowStepPositionV1: { + x: number; + y: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description One directed edge between two steps. */ + WorkflowTransitionV1: { + /** + * Format: uuid + * @description Caller-chosen on a write, exactly like a step id. + */ + id: string; + /** Format: uuid */ + from_step_id: string; + /** Format: uuid */ + to_step_id: string; + /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ + condition: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** @description Evaluation order among the edges leaving one step; lowest first. */ + priority: number; + }; + /** @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. */ + WorkflowGraphReplaceV1: { + /** @description The complete step set. Exactly one must be a `TRIGGER`. */ + steps: components["schemas"]["WorkflowStepV1"][]; + /** @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. */ + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @description One node of a workflow graph. */ + WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; + /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ + WorkflowTriggerStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "TRIGGER"; + config: { + eventName?: string; + intervalMs?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ + WorkflowSendEmailStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_EMAIL"; + config: { + /** Format: uuid */ + templateId?: string; + subject?: string; + body?: string; + recipient?: { + /** @enum {string} */ + type: "CONTACT" | "CUSTOM"; + /** Format: email */ + customEmail?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ + WorkflowDelayStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "DELAY"; + config: { + amount?: number; + /** @enum {string} */ + unit?: "minutes" | "hours" | "days"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ + WorkflowWaitForEventStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WAIT_FOR_EVENT"; + config: { + eventName?: string; + timeout?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @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. */ + WorkflowConditionStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "CONDITION"; + config: { + /** @enum {string} */ + mode?: "multi"; + field?: string; + /** @enum {string} */ + operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + branches?: ({ + id: string; + name: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + })[]; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Ends the run early and stamps `exit_reason`. */ + WorkflowExitStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "EXIT"; + config: { + reason?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @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. */ + WorkflowWebhookStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WEBHOOK"; + config: { + /** Format: uri */ + url?: string; + /** @enum {string} */ + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + body?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ + WorkflowUpdateContactStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "UPDATE_CONTACT"; + config: { + updates?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + subscribed?: boolean; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ + WorkflowSendAtOptimalTimeStepV1: { + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_AT_OPTIMAL_TIME"; + config: { + /** Format: uuid */ + templateId?: string; + fallbackHour?: number; + maxDelayHours?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ + WorkflowCloneV1: { + /** @description Name for the copy. Defaults to `Copy of `. */ + name?: string; + }; + /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ + WorkflowStateChangeV1: { + workflow: components["schemas"]["WorkflowV1"]; + /** @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). */ + cancelled_executions: number; + }; + /** @description Receipt for a single transactional send. */ + EmailV1: { + /** + * Format: uuid + * @description The Email row this send created. Quote it in support requests. + */ + id: string; + /** + * @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 {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; + /** + * Format: email + * @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. + */ + from: string; + }; + /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ + SendEmailV1: { + subject?: string; + body?: string; + /** Format: uuid */ + template?: string; + subscribed?: boolean; + name?: string; + from?: string | { + name?: string; + /** Format: email */ + email: string; + }; + /** Format: email */ + reply?: string; + headers?: { + [key: string]: string; + }; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + attachments?: { + filename: string; + content: string; + contentType: string; + contentId?: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + }[]; + tags?: string[]; + cc?: string[]; + bcc?: string[]; + /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ + to: string | { + name?: string; + /** Format: email */ + email: string; + }; + }; + /** @description Receipt for a sandbox test send. */ + EmailTestV1: { + /** + * Format: uuid + * @description The Email row this send created. + */ + id: string; + /** + * @description Delivery status at the moment of the response — `PENDING` for a send still queued. + * @enum {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; + /** + * Format: email + * @description This project's sandbox sender — resolved server-side, never from the body. + */ + from: string; + /** + * @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 {boolean} + */ + sandbox: true; + }; + /** @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. */ + SendTestEmailV1: { + /** + * Format: email + * @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. + */ + to?: string; + subject: string; + /** @description HTML body. Merge tags are rendered as on any other send. */ + body: string; + /** @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. */ + from?: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactV1List: { + data: components["schemas"]["ContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact as exposed on the v1 API. */ + ContactV1: { + /** Format: uuid */ + id: string; + email: string; + subscribed: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/contacts. */ + ContactV1Create: { + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @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. */ + ContactV1Update: { + subscribed?: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description Acknowledgement that a contact was deleted. */ + ContactV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/lists/{id}/subscribe. */ + ListSubscribe: { + /** Format: email */ + email: string; + /** @description Custom fields to upsert onto the contact as part of subscribing. */ + data?: { + [key: string]: unknown; + }; + /** + * @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. + * @default false + */ + allowResubscribe: boolean; + }; + /** @description Body for POST /api/lists/{id}/unsubscribe. */ + ListUnsubscribe: { + /** Format: email */ + email: string; + }; + /** @description Cursor-paginated list of subscriber lists. */ + ListV1List: { + data: components["schemas"]["ListV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A subscriber list as exposed on the v1 API. */ + ListV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + double_opt_in: boolean; + /** Format: uuid */ + confirmation_template_id: string | null; + redirect_url: string | null; + /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ + member_count: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/lists. */ + ListV1Create: { + name: string; + description?: string | null; + /** + * @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. + * @default false + */ + double_opt_in: boolean; + /** Format: uuid */ + confirmation_template_id?: string | null; + /** + * Format: uri + * @description Where a confirmed contact is sent after following the confirmation link. + */ + redirect_url?: string | null; + }; + /** @description Body for PATCH /api/v1/lists/{id}. */ + ListV1Update: { + name?: string; + description?: string | null; + double_opt_in?: boolean; + /** Format: uuid */ + confirmation_template_id?: string | null; + /** Format: uri */ + redirect_url?: string | null; + }; + /** @description Acknowledgement that a list was deleted. */ + ListV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @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. */ + AddDomainBody: { + /** Format: uuid */ + projectId?: string; + domain: string; + /** + * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. + * @enum {string} + */ + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"]; + /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ + streamDefault?: boolean; + }; + /** + * @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 {string} + */ + SendingStream: "TRANSACTIONAL" | "MARKETING"; + /** @description Body for PATCH /api/domains/{id}. */ + AssignDomainStream: { + /** + * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. + * @enum {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @description Make this the project's default identity for its stream, demoting whichever held it. */ + streamDefault?: boolean; + /** + * Format: email + * @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. + */ + defaultFromAddress?: string | null; + }; + /** @description Cursor-paginated list of sending domains. */ + DomainV1List: { + data: components["schemas"]["DomainV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A sending domain as exposed on the v1 API. */ + DomainV1: { + /** Format: uuid */ + id: string; + domain: string; + verified: boolean; + region: string | null; + stream: components["schemas"]["SendingStream"] & (string | null); + stream_default: boolean; + default_from_address: string | null; + mail_from_domain: string | null; + /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ + mail_from_domain_status: string | null; + dkim_verified: boolean; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/domains. */ + DomainV1Create: { + domain: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"] & unknown; + /** @description Make this the project's default identity for `stream`. Requires `stream`. */ + stream_default?: boolean; + }; + /** @description Acknowledgement that a sending domain was removed. */ + DomainV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/templates. */ + CreateTemplate: { + name: string; + description?: string; + subject: string; + body: string; + /** Format: email */ + from: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Body for PATCH /api/templates/{id}. */ + UpdateTemplate: { + name?: string; + description?: string; + subject?: string; + body?: string; + /** Format: email */ + from?: string; + fromName?: string | null; + /** Format: email */ + replyTo?: string | null; + /** @enum {string} */ + emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Cursor-paginated list of templates. */ + TemplateV1List: { + data: components["schemas"]["TemplateV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description An email template as exposed on the v1 API. */ + TemplateV1: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + subject: string; + body: string; + from: string; + from_name: string | null; + reply_to: string | null; + /** @enum {string} */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/templates. */ + TemplateV1Create: { + name: string; + description?: string | null; + subject: string; + body: string; + /** + * Format: email + * @description Sender address. Its domain must be verified for this project. + */ + from: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Body for PATCH /api/v1/templates/{id}. */ + TemplateV1Update: { + name?: string; + description?: string | null; + subject?: string; + body?: string; + /** Format: email */ + from?: string; + from_name?: string | null; + /** Format: email */ + reply_to?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; + /** @description Acknowledgement that a template was deleted. */ + TemplateV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/snippets. */ + CreateSnippet: { + name: string; + description?: string | null; + body: string; + }; + /** @description Body for PATCH /api/snippets/{id}. */ + UpdateSnippet: { + name?: string; + description?: string | null; + body?: string; + }; + /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ + CreateWebhook: { + /** Format: uri */ + url: string; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + }; + /** @description Body for PATCH /api/webhooks/{id}. */ + UpdateWebhook: { + /** Format: uri */ + url?: string; + eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + }; + /** @description Cursor-paginated list of webhook endpoints. */ + WebhookV1List: { + data: components["schemas"]["WebhookV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @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. */ + WebhookV1: { + /** Format: uuid */ + id: string; + url: string; + event_types: string[]; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description A newly created webhook and its one-time signing secret. */ + WebhookV1Created: { + webhook: components["schemas"]["WebhookV1"]; + /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + }; + /** @description Body for POST /api/v1/webhooks. */ + WebhookV1Create: { + /** Format: uri */ + url: string; + event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + }; + /** @description Body for PATCH /api/v1/webhooks/{id}. */ + WebhookV1Update: { + /** Format: uri */ + url?: string; + event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + }; + /** @description Acknowledgement that a webhook was deleted. */ + WebhookV1Deleted: { + /** Format: uuid */ + id: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ + WebhookV1SecretRotated: { + /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + /** + * Format: date-time + * @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. + */ + previous_secret_expires_at: string; + }; + CreateApiKeyBody: { + name: string; + /** @enum {string} */ + legacyGrantPreset?: "FULL" | "SENDING_ONLY"; + /** + * @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 {string} + */ + mode?: "LIVE" | "TEST"; + /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ + scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + /** Format: uuid */ + domainId?: string | null; + }; + /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ + AddSuppression: { + /** Format: email */ + email: string; + /** + * @default MANUAL + * @enum {string} + */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; + /** @description Cursor-paginated list of suppressed addresses. */ + SuppressionV1List: { + data: components["schemas"]["SuppressionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A suppressed address as exposed on the v1 API. */ + SuppressionV1: { + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + source: string; + /** Format: date-time */ + created_at: string; + }; + /** @description Body for POST /api/v1/suppressions. */ + SuppressionV1Create: { + /** Format: email */ + email: string; + /** + * @default MANUAL + * @enum {string} + */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; + /** @description Acknowledgement that an address was un-suppressed. */ + SuppressionV1Deleted: { + email: string; + /** @enum {boolean} */ + deleted: true; + }; + /** @description Body for POST /api/track — record a custom event for a contact. */ + TrackEvent: { + event: string; + /** Format: email */ + email: string; + subscribed?: boolean; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + }; + /** @description Cursor-paginated list of events, newest first. */ + EventV1List: { + data: components["schemas"]["EventV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A recorded custom event. */ + EventV1: { + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + contact_id: string | null; + /** Format: uuid */ + email_id: string | null; + /** @description The payload recorded with the event, or null. */ + payload: { + [key: string]: unknown; + } | null; + /** Format: date-time */ + created_at: string; + }; + /** @description Body for POST /api/v1/events. */ + EventTrackV1: { + /** @description Event name, e.g. `user.signup`. */ + name: string; + /** + * Format: uuid + * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. + */ + contact_id?: string; + /** @description Arbitrary event payload. */ + payload?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + }; + /** @description Every distinct event name in the project, most frequent first. */ + EventNamesV1: { + data: string[]; + }; + /** @description Per-name event counts over the applied window. */ + EventStatsV1: { + data: { + name: string; + count: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description The time range this response was computed over, after the 90-day clamp. */ + AnalyticsWindowV1: { + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + }; + /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ + AnalyticsTimeseriesV1: { + data: { + /** Format: date-time */ + date: string; + emails: number; + delivered: number; + opens: number; + clicks: number; + bounces: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Campaign counters and engagement over the window. */ + AnalyticsCampaignStatsV1: { + total: number; + /** @description Campaigns in DRAFT or SCHEDULED. */ + active: number; + completed: number; + /** @description Percentage, one decimal place. */ + average_open_rate: number; + average_click_rate: number; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Sent campaigns ranked by open rate. */ + AnalyticsTopCampaignsV1: { + data: { + /** Format: uuid */ + id: string; + subject: string; + sent: number; + opened: number; + clicked: number; + open_rate: number; + click_rate: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @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. */ + DeliverabilityDiagnosisV1: { + domain: string; + address: string | null; + /** Format: date-time */ + checked_at: string; + identity: components["schemas"]["DeliverabilityIdentityV1"]; + suppression: components["schemas"]["DeliverabilitySuppressionV1"]; + recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; + /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ + findings: components["schemas"]["DeliverabilityFindingV1"][]; + }; + /** @description The sending identity's DNS health, as last refreshed. */ + DeliverabilityIdentityV1: { + /** @description Whether this project has a domain record at all. False makes every other field null. */ + registered: boolean; + verified: boolean; + /** + * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. + * @enum {string|null} + */ + dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description SPF alignment for the sending identity. + * @enum {string|null} + */ + spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description The DMARC policy published at `_dmarc.`. + * @enum {string|null} + */ + dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description Inbound receiving only. Null unless the domain has receiving enabled. + * @enum {string|null} + */ + mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + mail_from_domain: string | null; + /** @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. */ + mail_from_domain_status: string | null; + /** + * Format: date-time + * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. + */ + last_checked_at: string | null; + }; + /** @description Null unless the request named an `address`. */ + DeliverabilitySuppressionV1: { + suppressed: boolean; + /** @enum {string|null} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; + /** @enum {string|null} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; + /** Format: date-time */ + suppressed_at: string | null; + } | null; + /** @description Delivery outcomes over the requested window. */ + DeliverabilityRecentDeliveryV1: { + window_days: number; + /** + * @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 {string} + */ + scope: "project"; + sent: number; + delivered: number; + bounced: number; + complained: number; + failed: number; + /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ + bounce_rate: number | null; + complaint_rate: number | null; + }; + /** @description One diagnosed problem, with its fix. */ + DeliverabilityFindingV1: { + /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ + code: string; + severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; + /** @description What is wrong, in one sentence. */ + summary: string; + /** @description What to do about it. */ + remedy: string; + }; + /** + * @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 {string} + */ + DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; + /** @description Cursor-paginated recipient-domain rollup, newest day first. */ + RecipientDomainStatsV1List: { + data: components["schemas"]["RecipientDomainStatsV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Delivery outcomes for one recipient domain on one day. */ + RecipientDomainStatsV1: { + /** @description The recipient's domain, lowercased: the part after the `@`. */ + domain: string; + /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ + day: string; + sent: number; + delivered: number; + bounced: number; + complained: number; + opened: number; + /** + * Format: date-time + * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. + */ + computed_at: string; + }; + /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ + DmarcReportV1List: { + data: components["schemas"]["DmarcReportV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One DMARC aggregate (RUA) report. */ + DmarcReportV1: { + id: string; + /** @description The receiver's own id for this report. */ + report_id: string; + /** @description The reporting receiver, e.g. `google.com`. */ + org_name: string; + /** @description The domain of yours the report is about. */ + policy_domain: string; + /** Format: date-time */ + range_begin: string; + /** Format: date-time */ + range_end: string; + total_count: number; + /** @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. */ + pass_count: number; + fail_count: number; + /** @description Per-sending-source rows, as the receiver reported them. */ + sources: { + source_ip: string; + count: number; + disposition: string; + dkim: string; + spf: string; + header_from: string; + }[]; + /** Format: date-time */ + received_at: string; + }; + /** @description Current email usage against the limits that are actually enforced. */ + UsageV1: { + /** + * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. + * @enum {string} + */ + plan: "free" | "pro" | "custom"; + monthly: { + emails_sent: number; + /** @description Monthly cap on the total. Null when per-category limits govern instead. */ + limit: number | null; + categories: { + transactional: { + emails_sent: number; + limit: number | null; + }; + campaign: { + emails_sent: number; + limit: number | null; + }; + workflow: { + emails_sent: number; + limit: number | null; + }; + inbound: { + emails_sent: number; + limit: number | null; + }; + }; + }; + daily: { + /** @description Today's sends. Null when the counter could not be read. */ + emails_sent: number | null; + limit: number; + /** @enum {string} */ + trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + }; + }; + /** @description The project the presented credential is scoped to. */ + ProjectV1: { + /** Format: uuid */ + id: string; + name: string; + /** @description A disabled project sends nothing; every send is refused. */ + disabled: boolean; + /** @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. */ + sandbox_address: string | null; + /** @description Locked once the first domain is added. */ + ses_region: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; + /** Format: date-time */ + created_at: string; + }; + /** @description A receiving mailbox on one of the project's verified domains. */ + Mailbox: { + /** Format: uuid */ + id: string; + /** + * Format: email + * @description The full mailbox address, e.g. `support@superbooks.io`. + */ + address: string; + displayName: string | null; + /** + * @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 {string} + */ + status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; + /** @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. */ + quotaBytes: number | null; + /** + * Format: uuid + * @description The verified domain this mailbox lives on. + */ + domainId: string; + /** Format: date-time */ + createdAt: string; + }; + /** @description A mailbox plus its IMAP/SMTP connection settings. */ + MailboxDetail: components["schemas"]["Mailbox"] & { + /** @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. */ + settings: { + imap: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + smtp: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + }; + }; + CreateMailboxBody: { + /** + * Format: uuid + * @description Defaults to the project the credential resolves to. Naming a different one is refused. + */ + projectId?: string; + /** + * Format: uuid + * @description A VERIFIED domain belonging to this project. + */ + domainId: string; + /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ + localPart: string; + displayName?: string; + /** @description NOT IMPLEMENTED — sending any value answers 400. */ + quotaBytes?: number; + }; + /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ + DraftMailboxMessage: { + /** @enum {string} */ + mode: "draft" | "rewrite" | "subject"; + brief?: string; + draft?: string; + instruction?: string; + /** @enum {string} */ + tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; + recipientContext?: string; + senderAddress?: string; + }; + /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ + ComposeMailboxMessage: { + to: string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + body: string; + }; + /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ + AppPassword: { + /** Format: uuid */ + id: string; + /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ + name: string; + /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ + scopes: ("imap" | "smtp")[]; + /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ + lastFour: string; + /** + * Format: date-time + * @description Null until a mail client has authenticated with it at least once. + */ + lastUsedAt: string | null; + /** Format: date-time */ + createdAt: string; + }; + /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ + AppPasswordReveal: { + /** Format: uuid */ + id: string; + /** + * Format: uri + * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the link stops working. Five minutes after creation; the password itself does not expire. + */ + revealExpiresAt: string; + }; + /** @description Body for POST /api/mailboxes/:id/app-passwords. */ + CreateAppPassword: { + name: string; + /** + * @default [ + * "imap", + * "smtp" + * ] + */ + scopes: ("imap" | "smtp")[]; + }; + /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ + VerifyEmail: { + /** Format: email */ + email: string; + }; + TopicListV1: { + data: components["schemas"]["TopicV1"][]; + cursor: string | null; + has_more: boolean; + }; + /** @description One subject this project mails about. */ + TopicV1: { + id: string; + /** @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. */ + key: string; + name: string; + description: string | null; + /** @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`. */ + default_opt_in: boolean; + /** @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. */ + archived: boolean; + /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ + subscribed_count: number; + unsubscribed_count: number; + /** Format: date-time */ + created_at: string; + }; + TopicCreateV1: { + /** @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. */ + key: string; + name: string; + description?: string | null; + default_opt_in?: boolean; + }; + /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ + TopicUpdateV1: { + name?: string; + description?: string | null; + default_opt_in?: boolean; + archived?: boolean; + }; + TopicSubscriptionV1: { + topic_id: string; + contact_id: string; + status: components["schemas"]["TopicSubscriptionStatusV1"]; + /** Format: date-time */ + confirmed_at: string | null; + /** @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. */ + confirmation_url: string | null; + }; + /** + * @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 {string} + */ + TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; + TopicSubscribeV1: { + /** Format: uuid */ + contact_id: string; + /** @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. */ + subscribed: boolean; + }; + /** @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. */ + ContactTopicPreferencesV1: { + contact_id: string; + /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ + subscribed: boolean; + topics: { + topic_id: string; + /** @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. */ + key: string; + name: string; + /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ + subscribed: boolean; + pending: boolean; + }[]; + }; + /** @description One verdict per address, in the order they were given. */ + EmailValidationBatchV1: { + results: components["schemas"]["EmailValidationV1"][]; + }; + /** @description One address's verdict, with the evidence behind it. */ + EmailValidationV1: { + email: string; + verdict: components["schemas"]["EmailValidationVerdictV1"]; + /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ + is_disposable: boolean; + /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ + is_role_address: boolean; + /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ + is_personal: boolean; + /** @description The domain publishes MX records. */ + has_mx_records: boolean; + /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ + reasons: string[]; + }; + /** + * @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 {string} + */ + EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; + EmailValidationBatchRequestV1: { + /** @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. */ + emails: string[]; + }; + /** @description One bulk validation run over a list. */ + EmailValidationRunV1: { + id: string; + list_id: string | null; + /** @enum {string} */ + status: "pending" | "running" | "completed" | "failed"; + /** @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. */ + processed_count: number; + deliverable_count: number; + undeliverable_count: number; + risky_count: number; + /** Format: date-time */ + started_at: string | null; + /** Format: date-time */ + completed_at: string | null; + /** @description Set only on `failed`. Prose for an operator; never parse it. */ + failure_reason: string | null; + /** Format: date-time */ + created_at: string; + }; + /** @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. */ + EmailValidationResultListV1: { + data: (components["schemas"]["EmailValidationV1"] & { + contact_id: string | null; + })[]; + /** @description Pass as `cursor` for the next page; null on the last. */ + cursor: string | null; + has_more: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + v1ListCampaigns: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateCampaign: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Create"]; + }; + }; + responses: { + /** @description Campaign created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1Deleted"]; + }; + }; + /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Update"]; + }; + }; + responses: { + /** @description The updated campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendCampaign: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CampaignV1Send"]; + }; + }; + responses: { + /** @description The campaign, now `SENDING` or `SCHEDULED` */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CancelCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The cancelled campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1PauseCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The paused campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only a `SENDING` campaign can be paused. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ResumeCampaign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The resumed campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1"]; + }; + }; + /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetCampaignStats: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Campaign statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1Stats"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListCampaignFailures: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Failed sends */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1FailureList"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1RetryCampaignFailures: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The retry was queued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CampaignV1RetryFailed"]; + }; + }; + /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a retry is already running for this campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListSegments: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateSegment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Create"]; + }; + }; + responses: { + /** @description Segment created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The segment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1Deleted"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the segment is still used by one or more active campaigns. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateSegment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Update"]; + }; + }; + responses: { + /** @description The updated segment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListSegmentContacts: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Segment member list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SegmentContactV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListWorkflows: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateWorkflow: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowCreateV1"]; + }; + }; + responses: { + /** @description Workflow created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1DeleteWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowDeletedV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the workflow still has running executions. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1UpdateWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowUpdateV1"]; + }; + }; + responses: { + /** @description Updated workflow */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the trigger cannot be changed while executions are running. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ListWorkflowExecutions: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only executions in this state. */ + status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + }; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Execution list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1StartWorkflowExecution: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowExecutionStartV1"]; + }; + }; + responses: { + /** @description Execution started */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CancelWorkflowExecution: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow execution id. */ + execution_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancelled execution */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowExecutionV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no execution with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflowStats: { + parameters: { + query?: { + /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ + from?: string | null; + }; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workflow statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStatsV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1GetWorkflowGraph: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow's graph */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowGraphV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ReplaceWorkflowGraph: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; + }; + }; + responses: { + /** @description The graph as it now stands */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowGraphV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CloneWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["WorkflowCloneV1"]; + }; + }; + responses: { + /** @description The cloned workflow */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1PauseWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow, and the number of runs this call cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStateChangeV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1ResumeWorkflow: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Workflow id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow, with `cancelled_executions` always 0 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowStateChangeV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmailV1"]; + }; + }; + responses: { + /** @description Email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendTestEmail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendTestEmailV1"]; + }; + }; + responses: { + /** @description Test email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailTestV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + listEmails: { + parameters: { + query?: { + limit?: number; + cursor?: string; + tag?: string; + /** @description Delivery lifecycle of the message. Engagement is reported separately. */ + status?: components["schemas"]["EmailDeliveryStatus"]; + from?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Email list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailListResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + sendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmail"]; + }; + }; + responses: { + /** @description Email accepted / sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendEmailResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getEmail: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Email and its delivery history */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailDetailResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + sendEmailBatch: { + parameters: { + query?: never; + header?: { + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchSendBody"]; + }; + }; + responses: { + /** @description All entries sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Partial success — at least one entry failed */ + 207: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + cancelScheduledEmail: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Email cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Email already past PENDING */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listContacts: { + parameters: { + query?: { + limit?: number; + cursor?: string; + search?: string; + subscribed?: "true" | "false"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contact list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactListResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateContact"]; + }; + }; + responses: { + /** @description Contact created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Email already exists for this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + upsertContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateContact"]; + }; + }; + responses: { + /** @description Contact created or updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + bulkCreateContacts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkCreateBody"]; + }; + }; + responses: { + /** @description Bulk-create result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + created: number; + skipped: number; + errors: { + index: number; + message: string; + }[]; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + bulkDeleteContacts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkDeleteBody"]; + }; + }; + responses: { + /** @description Bulk-delete result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + deleted: number; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + deleteContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contact deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IdResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + updateContact: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateContactBody"]; + }; }; - /** @description Receipt for a single transactional send. */ - EmailV1: { - /** - * Format: email - * @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. - */ - from: string; - /** - * Format: uuid - * @description The Email row this send created. Quote it in support requests. - */ - id: string; - /** - * @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 {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; + responses: { + /** @description Updated contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Contact"]; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; - /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ - Error: { - error: { - code: string; - details?: { - errors: unknown[]; + }; + v1ListContacts: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the email address. */ + search?: string; + /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ + subscribed?: "true" | "false"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contact list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1List"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; - message: string; }; - /** @enum {boolean} */ - success?: false; }; - /** @description Every distinct event name in the project, most frequent first. */ - EventNamesV1: { - data: string[]; + }; + v1CreateContact: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description Per-name event counts over the applied window. */ - EventStatsV1: { - data: { - count: number; - name: string; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Create"]; + }; }; - /** @description Body for POST /api/v1/events. */ - EventTrackV1: { - /** - * Format: uuid - * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. - */ - contact_id?: string; - /** @description Arbitrary event payload. */ - data?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + responses: { + /** @description The created contact */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — a contact with this email already exists in this project. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @description Event name, e.g. `user.signup`. */ - name: string; - }; - /** @description A recorded custom event. */ - EventV1: { - /** Format: uuid */ - contact_id: string | null; - /** Format: date-time */ - created_at: string; - /** @description The payload recorded with the event, or null. */ - data: { - [key: string]: unknown; - } | null; - /** Format: uuid */ - email_id: string | null; - /** Format: uuid */ - id: string; - name: string; - }; - /** @description Cursor-paginated list of events, newest first. */ - EventV1List: { - data: components["schemas"]["EventV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description A filter condition: one or more groups combined with `logic`. */ - FilterConditionV1: { - groups: components["schemas"]["FilterGroupV1"][]; - /** @enum {string} */ - logic: "AND" | "OR"; - }; - /** @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. */ - FilterGroupV1: { - conditions?: components["schemas"]["FilterConditionV1"]; - filters: components["schemas"]["SegmentFilterV1"][]; }; - /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ - IdResponse: { - data: { - /** Format: uuid */ + }; + v1GetContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ id: string; }; - /** @enum {boolean} */ - success: true; + cookie?: never; }; - /** @description Body for POST /api/lists/{id}/subscribe. */ - ListSubscribe: { - /** - * @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. - * @default false - */ - allowResubscribe: boolean; - /** @description Custom fields to upsert onto the contact as part of subscribing. */ - data?: { - [key: string]: unknown; + requestBody?: never; + responses: { + /** @description The contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; }; - /** Format: email */ - email: string; - }; - /** @description Result of a list-subscribe call. */ - ListSubscribeResponse: { - data: { - /** @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. */ - confirmToken?: string; - /** @description True when the membership row did not exist before this call. */ - created: boolean; - /** Format: uuid */ - membershipId: string; - /** - * @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 {string|null} - */ - previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; - /** @enum {string} */ - status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/lists/{id}/unsubscribe. */ - ListUnsubscribe: { - /** Format: email */ - email: string; - }; - /** @description Echoes the address that was unsubscribed. */ - ListUnsubscribeResponse: { - data: { - /** Format: email */ - email: string; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @enum {boolean} */ - success: true; }; - /** @description A receiving mailbox on one of the project's verified domains. */ - Mailbox: { - /** - * Format: email - * @description The full mailbox address, e.g. `support@superbooks.io`. - */ - address: string; - /** Format: date-time */ - createdAt: string; - displayName: string | null; - /** - * Format: uuid - * @description The verified domain this mailbox lives on. - */ - domainId: string; - /** Format: uuid */ - id: string; - /** @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. */ - quotaBytes: number | null; - /** - * @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 {string} - */ - status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; + }; + v1DeleteContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description A mailbox plus its IMAP/SMTP connection settings. */ - MailboxDetail: components["schemas"]["Mailbox"] & { - /** @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. */ - settings: { - imap: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + requestBody?: never; + responses: { + /** @description Contact deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1Deleted"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; }; - smtp: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ - Problem: { - /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ - code: string; - /** @description Explanation specific to this occurrence. */ - detail?: string; - /** @description Field-level failures. Present on 422 `validation_error` responses. */ - errors?: { - code: string; - message: string; - /** @description RFC 6901 JSON Pointer to the offending field. */ - pointer: string; - }[]; - /** @description Request path the failure occurred on. */ - instance?: string; - /** @description Correlation id — quote it in support requests. */ - request_id?: string; - /** @description HTTP status code, repeated in the body. */ - status: number; - /** @description Short, stable summary — the same for every occurrence of a `type`. */ - title: string; - /** - * Format: uri - * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. - */ - type: string; - }; - ProjectRecord: { - billingLimitCampaigns: number | null; - billingLimitInbound: number | null; - billingLimitTransactional: number | null; - billingLimitWorkflows: number | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - disabled: boolean; - disabledReason: string | null; - /** Format: uuid */ - id: string; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - name: string; - organizationId: string | null; - /** @description Local-part of the sandbox quick-start sender; null until first derived. */ - sandboxHandle: string | null; - sesRegion: string | null; - stripeCustomerId: string | null; - stripeSubscriptionId: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description The project the presented credential is scoped to. */ - ProjectV1: { - /** Format: date-time */ - created_at: string; - /** @description A disabled project sends nothing; every send is refused. */ - disabled: boolean; - /** Format: uuid */ - id: string; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - name: string; - /** @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. */ - sandbox_address: string | null; - /** @description Locked once the first domain is added. */ - ses_region: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - }; - /** @description A contact belonging to a segment. */ - SegmentContactV1: { - /** Format: date-time */ - created_at: string; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: { - [key: string]: unknown; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - email: string; - /** Format: uuid */ - id: string; - subscribed: boolean; - }; - /** @description Cursor-paginated list of the contacts belonging to a segment. */ - SegmentContactV1List: { - data: components["schemas"]["SegmentContactV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @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). */ - SegmentFilterV1: { - field: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; - /** @enum {string} */ - unit?: "days" | "hours" | "minutes"; - value?: unknown; - }; - /** @description A segment as exposed on the v1 API. */ - SegmentV1: { - condition: components["schemas"]["FilterConditionV1"] | null; - /** Format: date-time */ - created_at: string; - description: string | null; - /** Format: uuid */ - id: string; - member_count: number; - name: string; - track_membership: boolean; - /** @enum {string} */ - type: "DYNAMIC" | "STATIC"; - /** Format: date-time */ - updated_at: string; - }; - /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ - SegmentV1Create: { - condition?: components["schemas"]["FilterConditionV1"]; - description?: string; - name: string; - /** - * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. - * @default false - */ - track_membership: boolean; - /** - * @default DYNAMIC - * @enum {string} - */ - type: "DYNAMIC" | "STATIC"; - }; - /** @description Acknowledgement that a segment was deleted. */ - SegmentV1Deleted: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; }; - /** @description Cursor-paginated list of segments. */ - SegmentV1List: { - data: components["schemas"]["SegmentV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + }; + v1UpdateContact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource id. */ + id: string; + }; + cookie?: never; }; - /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ - SegmentV1Update: { - condition?: components["schemas"]["FilterConditionV1"]; - description?: string; - name?: string; - track_membership?: boolean; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Update"]; + }; }; - /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ - SendEmail: { - attachments?: { - content: string; - contentId?: string; - contentType: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - filename: string; - }[]; - bcc?: string[]; - body?: string; - cc?: string[]; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + responses: { + /** @description The updated contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContactV1"]; + }; }; - from?: string | { - /** Format: email */ - email: string; - name?: string; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - headers?: { - [key: string]: string; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - name?: string; - /** Format: email */ - reply?: string; - subject?: string; - subscribed?: boolean; - tags?: string[]; - /** Format: uuid */ - template?: string; - to: string | { - /** Format: email */ - email: string; - name?: string; - } | (string | { - /** Format: email */ - email: string; - name?: string; - })[]; - }; - /** @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`. */ - SendEmailData: { - emails: components["schemas"]["SendEmailRecipientResult"][]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; }; - /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ - SendEmailRecipientResult: { - contact: { - /** Format: email */ - email: string; - /** Format: uuid */ + }; + subscribeToList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description List id. */ id: string; }; - /** Format: uuid */ - email: string; + cookie?: never; }; - /** @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. */ - SendEmailResponse: { - data: components["schemas"]["SendEmailData"]; - /** @enum {boolean} */ - success: true; + requestBody: { + content: { + "application/json": components["schemas"]["ListSubscribe"]; + }; }; - /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ - SendEmailV1: { - attachments?: { - content: string; - contentId?: string; - contentType: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - filename: string; - }[]; - bcc?: string[]; - body?: string; - cc?: string[]; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + responses: { + /** @description Contact subscribed, or an existing membership returned unchanged */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSubscribeResponse"]; + }; }; - from?: string | { - /** Format: email */ - email: string; - name?: string; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - headers?: { - [key: string]: string; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - name?: string; - /** Format: email */ - reply?: string; - subject?: string; - subscribed?: boolean; - tags?: string[]; - /** Format: uuid */ - template?: string; - /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ - to: string | { - /** Format: email */ - email: string; - name?: string; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - }; - /** @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. */ - SendTestEmailV1: { - /** @description HTML body. Merge tags are rendered as on any other send. */ - body: string; - /** @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. */ - from?: string; - subject: string; - /** - * Format: email - * @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. - */ - to?: string; - }; - /** @description Bare success envelope with no payload. */ - SuccessEmpty: { - /** @enum {boolean} */ - success: true; - }; - /** @description A single suppressed-email record. */ - Suppression: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** Format: email */ - email: string; - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD"; - }; - /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ - SuppressionCheckResponse: { - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt?: string; - /** @enum {string} */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; - suppressed: boolean; - }; - /** @description Cursor-paginated list of suppressions. */ - SuppressionListResponse: { - cursor?: string | null; - data: components["schemas"]["Suppression"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; - }; - /** @description A reusable email template. */ - Template: { - body: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - description?: string | null; - /** Format: email */ - from: string; - fromName?: string | null; - /** Format: uuid */ - id: string; - name: string; - /** Format: uuid */ - projectId: string; - /** Format: email */ - replyTo?: string | null; - subject: string; - /** @enum {string} */ - type: "MARKETING" | "TRANSACTIONAL" | "HEADLESS"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description Cursor-paginated list of templates. */ - TemplateListResponse: { - data: { - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - data: components["schemas"]["Template"][]; - hasMore: boolean; - total: number; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/track — record a custom event for a contact. */ - TrackEvent: { - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** Format: email */ - email: string; - event: string; - subscribed?: boolean; }; - /** @description Response from POST /api/track. */ - TrackEventResponse: { - data: { - /** Format: uuid */ - contact: string; - /** Format: uuid */ - event: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; + }; + unsubscribeFromList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description List id. */ + id: string; }; - /** @enum {boolean} */ - success: true; + cookie?: never; }; - /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ - UpdateContactBody: { - customFields?: { - [key: string]: unknown; + requestBody: { + content: { + "application/json": components["schemas"]["ListUnsubscribe"]; }; - subscribed?: boolean; - }; - /** @description Body for PATCH /api/templates/{id}. */ - UpdateTemplate: { - body?: string; - description?: string; - /** Format: email */ - from?: string; - fromName?: string | null; - name?: string; - /** Format: email */ - replyTo?: string | null; - subject?: string; - /** @enum {string} */ - type?: "TRANSACTIONAL" | "MARKETING" | "HEADLESS"; - }; - /** @description Body for PATCH /api/webhooks/{id}. */ - UpdateWebhook: { - eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; - /** Format: uri */ - url?: string; }; - /** @description Current email usage against the limits that are actually enforced. */ - UsageV1: { - daily: { - /** @description Today's sends. Null when the counter could not be read. */ - emails_sent: number | null; - limit: number; - /** @enum {string} */ - trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + responses: { + /** @description Contact unsubscribed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListUnsubscribeResponse"]; + }; }; - monthly: { - categories: { - campaign: { - emails_sent: number; - limit: number | null; - }; - inbound: { - emails_sent: number; - limit: number | null; - }; - transactional: { - emails_sent: number; - limit: number | null; - }; - workflow: { - emails_sent: number; - limit: number | null; - }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; - emails_sent: number; - /** @description Monthly cap on the total. Null when per-category limits govern instead. */ - limit: number | null; }; - /** - * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. - * @enum {string} - */ - plan: "free" | "pro" | "custom"; - }; - /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ - VerifyEmail: { - /** Format: email */ - email: string; - }; - /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ - VerifyEmailResponse: { - data: { - /** Format: email */ - email: string; - reason?: string; - valid: boolean; - } & { - [key: string]: unknown; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description A user-managed outbound webhook. */ - Webhook: { - consecutiveFailures: number; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - disabledAt?: string | null; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** Format: uuid */ - id: string; - lastFour?: string; - /** Format: uuid */ - projectId: string; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - /** Format: uri */ - url: string; - }; - /** @description An attempted webhook delivery. */ - WebhookCall: { - attempt: number; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - eventType: string; - /** Format: uuid */ - id: string; - payload: { - [key: string]: unknown; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - responseBody?: string | null; - responseStatus?: number | null; - /** @enum {string} */ - status: "PENDING" | "SUCCESS" | "FAILED"; - /** Format: uuid */ - webhookId: string; - }; - /** @description Cursor-paginated list of recent calls for a single webhook. */ - WebhookCallsListResponse: { - cursor?: string | null; - data: components["schemas"]["WebhookCall"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; - }; - /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ - WebhookCreateResponse: { - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { - /** @description Plaintext shared secret. Returned ONCE on create. */ - secret: string; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Single webhook (no secret). */ - WebhookGetResponse: { - data: components["schemas"]["Webhook"]; - /** @enum {boolean} */ - success: true; - }; - /** @description List of webhooks for the auth'd project. */ - WebhookListResponse: { - data: components["schemas"]["Webhook"][]; - /** @enum {boolean} */ - success: true; }; - /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ - WebhookRotateSecretResponse: { - data: { - /** Format: uuid */ - id: string; - /** @description New plaintext shared secret. */ - secret: string; + }; + v1ListLists: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; - /** @enum {boolean} */ - success: true; - }; - /** @description Body for POST /api/v1/workflows. */ - WorkflowCreateV1: { - allow_reentry?: boolean; - description?: string; - /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ - enabled?: boolean; - /** @description The custom event that starts this workflow, e.g. `user.signup`. */ - event_name: string; - name: string; - }; - /** @description Confirmation that a workflow was deleted. */ - WorkflowDeletedV1: { - /** @enum {boolean} */ - deleted: true; - /** Format: uuid */ - id: string; + header?: never; + path?: never; + cookie?: never; }; - /** @description Body for POST /api/v1/workflows/{id}/executions. */ - WorkflowExecutionStartV1: { - /** - * Format: uuid - * @description Contact to enter the workflow. Must belong to this project. - */ - contact_id: string; - /** @description Extra variables merged into the contact's data for this run. */ - context?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + requestBody?: never; + responses: { + /** @description Subscriber lists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1List"]; + }; }; - }; - /** @description One contact's run through a workflow. */ - WorkflowExecutionV1: { - /** Format: date-time */ - completed_at: string | null; - /** Format: uuid */ - contact_id: string; - /** Format: uuid */ - current_step_id: string | null; - exit_reason: string | null; - /** Format: uuid */ - id: string; - /** Format: date-time */ - started_at: string; - /** @enum {string} */ - status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - /** Format: uuid */ - workflow_id: string; - }; - /** @description Cursor-paginated list of workflow executions, newest first. */ - WorkflowExecutionV1List: { - data: components["schemas"]["WorkflowExecutionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description Execution, email and conversion totals for one workflow. */ - WorkflowStatsV1: { - avg_duration_ms: number | null; - /** @description Execution counts keyed by status; a status with no executions is absent. */ - by_status: { - [key: string]: number; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ - completion_rate: number | null; - conversions: { - count: number; - event_name: string; - /** Format: uuid */ - goal_id: string; - name: string; - }[]; - emails: { - clicked: number; - opened: number; - sent: number; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; - total: number; - /** Format: uuid */ - workflow_id: string; }; - /** @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. */ - WorkflowUpdateV1: { - allow_reentry?: boolean; - description?: string; - enabled?: boolean; - event_name?: string; - /** @description Per-workflow start rate cap. `null` removes the cap. */ - max_executions_per_hour?: number | null; - name?: string; + }; + v1CreateList: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - /** @description An automation workflow as exposed on the v1 API. */ - WorkflowV1: { - allow_reentry: boolean; - /** Format: date-time */ - created_at: string; - description: string | null; - enabled: boolean; - /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ - event_name: string | null; - /** Format: uuid */ - id: string; - max_executions_per_hour: number | null; - name: string; - /** @enum {string} */ - trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; - /** Format: date-time */ - updated_at: string; - /** @description Incremented on every structural (step/transition) change. */ - version: number; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Create"]; + }; }; - /** @description Cursor-paginated list of workflows. */ - WorkflowV1List: { - data: components["schemas"]["WorkflowV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + responses: { + /** @description The created list */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - listContacts: { + v1GetList: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - subscribed?: "true" | "false"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact list */ + /** @description The list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactListResponse"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createContact: { + v1DeleteList: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateContact"]; + path: { + /** @description Resource id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Contact created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: components["schemas"]["Contact"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description List deleted */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ListV1Deleted"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Email already exists for this project */ - 409: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - bulkCreateContacts: { + v1UpdateList: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ContactBulkCreateBody"]; + "application/json": components["schemas"]["ListV1Update"]; }; }; responses: { - /** @description Bulk-create result */ + /** @description The updated list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - created: number; - errors: { - index: number; - message: string; - }[]; - skipped: number; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - bulkDeleteContacts: { + listDomains: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactBulkDeleteBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Bulk-delete result */ + /** @description Domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - deleted: number; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainListResponse"]; }; }; /** @description Validation error */ @@ -3262,15 +10150,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3291,7 +10170,7 @@ export interface operations { }; }; }; - upsertContact: { + addDomain: { parameters: { query?: never; header?: never; @@ -3300,20 +10179,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateContact"]; + "application/json": components["schemas"]["AddDomainBody"]; }; }; responses: { - /** @description Contact created or updated */ - 200: { + /** @description Domain added */ + 201: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Contact"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Domain"]; }; }; }; @@ -3344,8 +10223,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; @@ -3353,8 +10232,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; @@ -3362,8 +10241,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Internal server error */ - 500: { + /** @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. */ + 502: { headers: { [name: string]: unknown; }; @@ -3373,7 +10252,7 @@ export interface operations { }; }; }; - getContact: { + getDomain: { parameters: { query?: never; header?: never; @@ -3384,16 +10263,16 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Contact */ + /** @description Domain */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Contact"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Domain"]; }; }; }; @@ -3453,7 +10332,7 @@ export interface operations { }; }; }; - deleteContact: { + deleteDomain: { parameters: { query?: never; header?: never; @@ -3464,13 +10343,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Contact deleted */ + /** @description Domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; /** @description Validation error */ @@ -3529,7 +10408,7 @@ export interface operations { }; }; }; - updateContact: { + assignDomainStream: { parameters: { query?: never; header?: never; @@ -3540,20 +10419,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UpdateContactBody"]; + "application/json": components["schemas"]["AssignDomainStream"]; }; }; responses: { - /** @description Updated contact */ + /** @description Updated sending identity */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Contact"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Domain"]; }; }; }; @@ -3593,15 +10472,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -3622,22 +10492,28 @@ export interface operations { }; }; }; - listDomains: { + getDomainVerification: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain list */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainListResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["DomainVerificationStatus"]; + }; }; }; /** @description Validation error */ @@ -3658,8 +10534,17 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -3687,29 +10572,27 @@ export interface operations { }; }; }; - addDomain: { + verifyDomain: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddDomainBody"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Domain added */ - 201: { + /** @description Verification status */ + 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Domain"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["DomainVerificationStatus"]; }; }; }; @@ -3740,8 +10623,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -3749,8 +10632,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Internal server error */ - 500: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; @@ -3758,8 +10641,8 @@ export interface operations { "application/json": 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. */ - 502: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; @@ -3769,7 +10652,7 @@ export interface operations { }; }; }; - getDomain: { + startDomainSetup: { parameters: { query?: never; header?: never; @@ -3780,16 +10663,25 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Domain */ + /** @description Guided setup session */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["Domain"]; /** @enum {boolean} */ success: true; + data: { + token: string; + /** + * Format: uri + * @description Open this in a browser to publish the records. Short-lived and domain-specific. + */ + connectUrl: string; + /** @description When `connectUrl` stops working. */ + expiresAt: string; + }; }; }; }; @@ -3849,428 +10741,426 @@ export interface operations { }; }; }; - deleteDomain: { + v1ListDomains: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain removed */ + /** @description Sending domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DomainV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - startDomainSetup: { + v1CreateDomain: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DomainV1Create"]; + }; + }; responses: { - /** @description Guided setup session */ - 200: { + /** @description The registered sending domain, awaiting DNS */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - /** - * Format: uri - * @description Open this in a browser to publish the records. Short-lived and domain-specific. - */ - connectUrl: string; - /** @description When `connectUrl` stops working. */ - expiresAt: string; - token: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `conflict` — this domain is already registered to a project you can send from. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @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. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomainVerification: { + v1GetDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The sending domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["DomainVerificationStatus"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - verifyDomain: { + v1DeleteDomain: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description Sending domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["DomainVerificationStatus"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["DomainV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - }; - listEmails: { - parameters: { - query?: { - limit?: number; - cursor?: string; - tag?: string; - status?: "PENDING" | "SENT" | "DELIVERED" | "OPENED" | "CLICKED" | "BOUNCED" | "COMPLAINED" | "FAILED"; - from?: string; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; }; + }; + }; + v1VerifyDomain: { + parameters: { + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Email list */ + /** @description The sending domain, as SES and DNS now report it */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailListResponse"]; + "application/json": components["schemas"]["DomainV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - sendEmail: { + listTemplates: { parameters: { - query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; + query?: { + limit?: number; + cursor?: string; + search?: string; + emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendEmail"]; - }; - }; + requestBody?: never; responses: { - /** @description Email accepted / sent */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SendEmailResponse"]; + "application/json": components["schemas"]["TemplateListResponse"]; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -4288,7 +11178,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -4297,16 +11187,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; @@ -4333,51 +11214,35 @@ export interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - sendEmailBatch: { + createTemplate: { parameters: { query?: never; - header?: { - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["BatchSendBody"]; + "application/json": components["schemas"]["CreateTemplate"]; }; }; responses: { - /** @description All entries sent */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BatchSendResponse"]; - }; - }; - /** @description Partial success — at least one entry failed */ - 207: { + /** @description Template created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BatchSendResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -4395,7 +11260,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -4404,16 +11269,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; @@ -4440,18 +11296,9 @@ export interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - getEmail: { + getTemplate: { parameters: { query?: never; header?: never; @@ -4462,13 +11309,17 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Email */ + /** @description Template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailGetResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; /** @description Validation error */ @@ -4527,7 +11378,7 @@ export interface operations { }; }; }; - cancelScheduledEmail: { + deleteTemplate: { parameters: { query?: never; header?: never; @@ -4538,13 +11389,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Email cancelled */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailGetResponse"]; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -4583,7 +11434,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already past PENDING */ + /** @description Template still in use */ 409: { headers: { [name: string]: unknown; @@ -4612,29 +11463,32 @@ export interface operations { }; }; }; - subscribeToList: { + updateTemplate: { parameters: { query?: never; header?: never; path: { - /** @description List id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ListSubscribe"]; + "application/json": components["schemas"]["UpdateTemplate"]; }; }; responses: { - /** @description Contact subscribed, or an existing membership returned unchanged */ + /** @description Updated template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSubscribeResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Template"]; + }; }; }; /** @description Validation error */ @@ -4673,15 +11527,6 @@ export interface operations { "application/json": 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. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -4711,370 +11556,411 @@ export interface operations { }; }; }; - unsubscribeFromList: { + v1ListTemplates: { parameters: { - query?: never; - header?: never; - path: { - /** @description List id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the name. */ + search?: string; + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListUnsubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact unsubscribed */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUnsubscribeResponse"]; + "application/json": components["schemas"]["TemplateV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1CreateTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateV1Create"]; + }; + }; + responses: { + /** @description The created template */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listMailboxes: { + v1GetTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox list */ + /** @description The template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Mailbox"][]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createMailbox: { + v1DeleteTemplate: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateMailboxBody"]; + path: { + /** @description Resource id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Mailbox provisioned */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: components["schemas"]["Mailbox"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description Template deleted */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["TemplateV1Deleted"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ + /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ - 502: { + /** @description `internal_error`. */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getMailbox: { + v1UpdateTemplate: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateV1Update"]; + }; + }; responses: { - /** @description Mailbox with connection settings */ + /** @description The updated template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["MailboxDetail"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteMailbox: { + listSnippets: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + cursor?: string; + search?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox deleted */ + /** @description Snippet list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - /** @enum {boolean} */ - deleted: true; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["SnippetListResponse"]; }; }; /** @description Validation error */ @@ -5104,8 +11990,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -5133,27 +12019,29 @@ export interface operations { }; }; }; - listAppPasswords: { + createSnippet: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSnippet"]; + }; + }; responses: { - /** @description App password list */ - 200: { + /** @description Snippet created */ + 201: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["AppPassword"][]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5184,8 +12072,17 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -5213,7 +12110,7 @@ export interface operations { }; }; }; - createAppPassword: { + getSnippet: { parameters: { query?: never; header?: never; @@ -5222,22 +12119,18 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateAppPassword"]; - }; - }; + requestBody?: never; responses: { - /** @description App password created; the secret is behind the one-time link */ - 201: { + /** @description Snippet */ + 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - data: components["schemas"]["AppPasswordReveal"]; /** @enum {boolean} */ success: true; + data: components["schemas"]["Snippet"]; }; }; }; @@ -5297,32 +12190,24 @@ export interface operations { }; }; }; - revokeAppPassword: { + deleteSnippet: { parameters: { query?: never; header?: never; path: { id: string; - passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description App password revoked */ + /** @description Snippet deleted */ 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - data: { - /** @enum {boolean} */ - revoked: true; - }; - /** @enum {boolean} */ - success: true; - }; + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -5381,25 +12266,32 @@ export interface operations { }; }; }; - listApiKeys: { + updateSnippet: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSnippet"]; + }; + }; responses: { - /** @description API key list */ + /** @description Updated snippet */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiKeyListResponse"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Snippet"]; + }; }; }; /** @description Validation error */ @@ -5438,6 +12330,24 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5458,45 +12368,22 @@ export interface operations { }; }; }; - createApiKey: { + listWebhooks: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateApiKeyBody"]; - }; - }; + requestBody?: never; responses: { - /** @description API key created; the secret is behind the reveal link. */ - 201: { + /** @description Webhook list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @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. */ - data: components["schemas"]["ApiKey"] & { - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookListResponse"]; }; }; /** @description Validation error */ @@ -5546,27 +12433,26 @@ export interface operations { }; }; }; - revokeApiKey: { + createWebhook: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - /** @description API key id. */ - keyId: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateWebhook"]; + }; + }; responses: { - /** @description API key revoked */ - 200: { + /** @description Webhook created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["WebhookCreateResponse"]; }; }; /** @description Validation error */ @@ -5596,15 +12482,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5625,43 +12502,24 @@ export interface operations { }; }; }; - rotateApiKey: { + getWebhook: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ id: string; - /** @description API key id. */ - keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key rotated; the new secret is behind the reveal link. */ + /** @description Webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: { - lastFour: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - }; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; /** @description Validation error */ @@ -5720,26 +12578,24 @@ export interface operations { }; }; }; - listSuppressions: { + deleteWebhook: { parameters: { - query?: { - limit?: number; - cursor?: string; - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression list */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionListResponse"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; /** @description Validation error */ @@ -5769,6 +12625,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5789,26 +12654,28 @@ export interface operations { }; }; }; - addSuppression: { + updateWebhook: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AddSuppression"]; + "application/json": components["schemas"]["UpdateWebhook"]; }; }; responses: { - /** @description Suppression added */ - 201: { + /** @description Webhook updated */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Suppression"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; /** @description Validation error */ @@ -5838,6 +12705,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5858,25 +12734,24 @@ export interface operations { }; }; }; - checkSuppression: { + rotateWebhookSecret: { parameters: { query?: never; header?: never; path: { - /** @description URL-encoded email address */ - email: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression check result */ + /** @description Secret rotated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionCheckResponse"]; + "application/json": components["schemas"]["WebhookRotateSecretResponse"]; }; }; /** @description Validation error */ @@ -5906,6 +12781,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5926,24 +12810,28 @@ export interface operations { }; }; }; - removeSuppression: { + listWebhookCalls: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + }; header?: never; path: { - /** @description URL-encoded email address */ - email: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression removed */ - 204: { + /** @description Webhook call history */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["WebhookCallsListResponse"]; + }; }; /** @description Validation error */ 400: { @@ -5972,6 +12860,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -5992,13 +12889,12 @@ export interface operations { }; }; }; - listTemplates: { + v1ListWebhooks: { parameters: { query?: { limit?: number; - cursor?: string; - search?: string; - type?: "MARKETING" | "TRANSACTIONAL" | "HEADLESS"; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; header?: never; path?: never; @@ -6006,72 +12902,63 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Template list */ + /** @description Webhook list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateListResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createTemplate: { + v1CreateWebhook: { parameters: { query?: never; header?: never; @@ -6080,433 +12967,397 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateTemplate"]; + "application/json": components["schemas"]["WebhookV1Create"]; }; }; responses: { - /** @description Template created */ + /** @description The created webhook and its one-time signing secret */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1Created"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getTemplate: { + v1GetWebhook: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Template */ + /** @description The webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; + "application/json": components["schemas"]["WebhookV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteTemplate: { + v1DeleteWebhook: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Template deleted */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1Deleted"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Template still in use */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateTemplate: { + v1UpdateWebhook: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["UpdateTemplate"]; + "application/json": components["schemas"]["WebhookV1Update"]; }; }; responses: { - /** @description Updated template */ + /** @description The updated webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - data: components["schemas"]["Template"]; - /** @enum {boolean} */ - success: true; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - trackEvent: { + v1RotateWebhookSecret: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TrackEvent"]; + path: { + /** @description Resource id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Event tracked */ + /** @description The new signing secret and the moment the previous one stops verifying */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TrackEventResponse"]; + "application/json": components["schemas"]["WebhookV1SecretRotated"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createProject: { + listApiKeys: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - name: string; - /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. - * @enum {string} - */ - sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; + path: { + /** @description Project id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Project created */ - 201: { + /** @description API key list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectRecord"]; + "application/json": components["schemas"]["ApiKeyListResponse"]; }; }; /** @description Validation error */ @@ -6536,8 +13387,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -6565,223 +13416,274 @@ export interface operations { }; }; }; - v1GetCampaignAnalytics: { + createApiKey: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Campaign statistics */ - 200: { + requestBody: { + content: { + "application/json": components["schemas"]["CreateApiKeyBody"]; + }; + }; + responses: { + /** @description API key created; the secret is behind the reveal link. */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + /** @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. */ + data: components["schemas"]["ApiKey"] & { + /** + * Format: uri + * @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. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetAnalyticsTimeseries: { + rotateApiKey: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Daily time series */ + /** @description API key rotated; the new secret is behind the reveal link. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + lastFour: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListTopCampaigns: { + revokeApiKey: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - limit?: number; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Ranked campaigns */ + /** @description API key revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListCampaigns: { + listSuppressions: { parameters: { query?: { limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + cursor?: string; + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; header?: never; path?: never; @@ -6789,347 +13691,287 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Campaign list */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1List"]; + "application/json": components["schemas"]["SuppressionListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateCampaign: { + addSuppression: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Create"]; + "application/json": components["schemas"]["AddSuppression"]; }; }; responses: { - /** @description Campaign created */ + /** @description Suppression added */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Suppression"]; }; }; - /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetCampaign: { + checkSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The campaign */ + /** @description Suppression check result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionCheckResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteCampaign: { + removeSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign deleted */ - 200: { + /** @description Suppression removed */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CampaignV1Deleted"]; - }; + content?: never; }; - /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateCampaign: { + v1ListSuppressions: { parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Filter to one reason. Omit for every suppressed address. */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CampaignV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated campaign */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7150,15 +13992,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7188,34 +14021,26 @@ export interface operations { }; }; }; - v1CancelCampaign: { + v1CreateSuppression: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description The cancelled campaign */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CampaignV1"]; - }; + requestBody: { + content: { + "application/json": components["schemas"]["SuppressionV1Create"]; }; - /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ - 400: { + }; + responses: { + /** @description The suppressed address */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7236,15 +14061,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7274,34 +14090,25 @@ export interface operations { }; }; }; - v1PauseCampaign: { + v1GetSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The paused campaign */ + /** @description The suppression record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — only a `SENDING` campaign can be paused. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7322,7 +14129,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -7360,34 +14167,25 @@ export interface operations { }; }; }; - v1ResumeCampaign: { + v1DeleteSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ - id: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The resumed campaign */ + /** @description Address removed from the suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["SuppressionV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7408,15 +14206,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7446,63 +14235,101 @@ export interface operations { }; }; }; - v1SendCampaign: { + trackEvent: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path: { - /** @description Resource id. */ - id: string; - }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Send"]; + "application/json": components["schemas"]["TrackEvent"]; + }; + }; + responses: { + /** @description Event tracked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackEventResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; - }; - responses: { - /** @description The campaign, now `SENDING` or `SCHEDULED` */ - 200: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ - 400: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + }; + v1ListEvents: { + parameters: { + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only events with this exact name. */ + event_name?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Event list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["EventV1List"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; @@ -7510,8 +14337,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -7519,7 +14346,7 @@ export interface operations { "application/problem+json": 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. */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; @@ -7548,25 +14375,26 @@ export interface operations { }; }; }; - v1GetCampaignStats: { + v1TrackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EventTrackV1"]; + }; + }; responses: { - /** @description Campaign statistics */ - 200: { + /** @description Event recorded */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Stats"]; + "application/json": components["schemas"]["EventV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7587,7 +14415,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -7625,29 +14453,22 @@ export interface operations { }; }; }; - v1SendEmail: { + v1ListEventNames: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendEmailV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Email queued */ - 202: { + /** @description Event names */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailV1"]; + "application/json": components["schemas"]["EventNamesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7659,7 +14480,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; @@ -7668,25 +14489,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; @@ -7713,37 +14516,29 @@ export interface operations { "application/problem+json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1SendTestEmail: { + v1GetEventStats: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendTestEmailV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Test email queued */ - 202: { + /** @description Event counts */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailTestV1"]; + "application/json": components["schemas"]["EventStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7755,7 +14550,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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`. */ 403: { headers: { [name: string]: unknown; @@ -7764,16 +14559,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @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. */ 422: { headers: { [name: string]: unknown; @@ -7782,7 +14568,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 429: { headers: { [name: string]: unknown; @@ -7800,25 +14586,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1ListEvents: { + v1GetAnalyticsTimeseries: { parameters: { query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only events with this exact name. */ - event_name?: string; + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; header?: never; path?: never; @@ -7826,13 +14602,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Event list */ + /** @description Daily time series */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1List"]; + "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7882,26 +14658,27 @@ export interface operations { }; }; }; - v1TrackEvent: { + v1GetCampaignAnalytics: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["EventTrackV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Event recorded */ - 201: { + /** @description Campaign statistics */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1"]; + "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -7922,15 +14699,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no contact with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -7960,22 +14728,28 @@ export interface operations { }; }; }; - v1ListEventNames: { + v1ListTopCampaigns: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + limit?: number; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Event names */ + /** @description Ranked campaigns */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventNamesV1"]; + "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8025,13 +14799,15 @@ export interface operations { }; }; }; - v1GetEventStats: { + v1DiagnoseDeliverability: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + query: { + /** @description A sending domain in this project, e.g. `example.com`. */ + domain: 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. */ + address?: string; + /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ + window_days?: number; }; header?: never; path?: never; @@ -8039,13 +14815,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Event counts */ + /** @description The diagnosis, with findings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStatsV1"]; + "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8095,22 +14871,30 @@ export interface operations { }; }; }; - v1GetProject: { + v1ListRecipientDomainStats: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ + days?: number; + /** @description Restrict to one recipient domain. */ + domain?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The authenticated project */ + /** @description Cursor-paginated recipient-domain rollup */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectV1"]; + "application/json": components["schemas"]["RecipientDomainStatsV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8122,17 +14906,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — the project was deleted between authentication and this read. */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -8169,12 +14944,16 @@ export interface operations { }; }; }; - v1ListSegments: { + v1ListDmarcReports: { parameters: { query?: { limit?: number; /** @description Opaque cursor from a previous response's `next_cursor`. */ after?: string; + /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ + days?: number; + /** @description Restrict to reports about one of your domains. */ + domain?: string; }; header?: never; path?: never; @@ -8182,13 +14961,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Segment list */ + /** @description Cursor-paginated DMARC aggregate reports */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1List"]; + "application/json": components["schemas"]["DmarcReportV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8238,35 +15017,22 @@ export interface operations { }; }; }; - v1CreateSegment: { + v1GetUsage: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SegmentV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description Segment created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SegmentV1"]; - }; - }; - /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ - 400: { + /** @description Current usage */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["UsageV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8316,25 +15082,22 @@ export interface operations { }; }; }; - v1GetSegment: { + v1GetProject: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The segment */ + /** @description The authenticated project */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": components["schemas"]["ProjectV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -8355,7 +15118,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — the project was deleted between authentication and this read. */ 404: { headers: { [name: string]: unknown; @@ -8393,812 +15156,962 @@ export interface operations { }; }; }; - v1DeleteSegment: { + listMailboxes: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment deleted */ + /** @description Mailbox list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1Deleted"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Mailbox"][]; + }; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the segment is still used by one or more active campaigns. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateSegment: { + createMailbox: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SegmentV1Update"]; + "application/json": components["schemas"]["CreateMailboxBody"]; }; }; responses: { - /** @description The updated segment */ - 200: { + /** @description Mailbox provisioned */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["Mailbox"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegmentContacts: { + getMailbox: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment member list */ + /** @description Mailbox with connection settings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentContactV1List"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["MailboxDetail"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetUsage: { + deleteMailbox: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Current usage */ + /** @description Mailbox deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UsageV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + deleted: true; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflows: { + draftMailboxMessage: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DraftMailboxMessage"]; + }; + }; responses: { - /** @description Workflow list */ + /** @description A draft. Nothing was sent. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1List"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @description Suggested subject, or null. */ + subject: string | null; + /** @description Suggested plain-text body, or null. */ + body: string | null; + /** @description Alternative subject lines (`subject` mode); empty otherwise. */ + subjects: string[]; + /** + * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. + * @enum {boolean} + */ + sent: false; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The drafting model was unreachable or returned nothing usable. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateWorkflow: { + sendMailboxMessage: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowCreateV1"]; + "application/json": components["schemas"]["ComposeMailboxMessage"]; }; }; responses: { - /** @description Workflow created */ + /** @description Message submitted */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + submitted: true; + /** + * Format: uuid + * @description The conversation this send started. Replies thread onto it. + */ + conversationId: string; + /** + * Format: uuid + * @description The stored outbound message. + */ + messageId: string; + }; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The mail server refused the submission. Nothing was sent. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelWorkflowExecution: { + listAppPasswords: { parameters: { query?: never; header?: never; path: { - /** @description Workflow execution id. */ - execution_id: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Cancelled execution */ + /** @description App password list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["AppPassword"][]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no execution with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflow: { + createAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateAppPassword"]; + }; + }; responses: { - /** @description Workflow */ - 200: { + /** @description App password created; the secret is behind the one-time link */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: components["schemas"]["AppPasswordReveal"]; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteWorkflow: { + revokeAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; + passwordId: string; }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Workflow deleted */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WorkflowDeletedV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description App password revoked */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + /** @enum {boolean} */ + success: true; + data: { + /** @enum {boolean} */ + revoked: true; + }; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow still has running executions. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateWorkflow: { + createProject: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowUpdateV1"]; + "application/json": { + name: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; + }; }; }; responses: { - /** @description Updated workflow */ - 200: { + /** @description Project created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["ProjectRecord"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the trigger cannot be changed while executions are running. */ - 409: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + }; + }; + verifyEmailAddress: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VerifyEmail"]; + }; + }; + responses: { + /** @description Verification result */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["VerifyEmailResponse"]; }; }; - /** @description `internal_error`. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflowExecutions: { + v1ListTopics: { parameters: { query?: { + cursor?: string; limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only executions in this state. */ - status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + include_archived?: boolean | null; }; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Execution list */ + /** @description One page of topics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1List"]; + "application/json": components["schemas"]["TopicListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9219,15 +16132,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9257,29 +16161,26 @@ export interface operations { }; }; }; - v1StartWorkflowExecution: { + v1CreateTopic: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowExecutionStartV1"]; + "application/json": components["schemas"]["TopicCreateV1"]; }; }; responses: { - /** @description Execution started */ + /** @description The created topic */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9300,24 +16201,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9347,28 +16230,25 @@ export interface operations { }; }; }; - v1GetWorkflowStats: { + v1GetTopic: { parameters: { - query?: { - /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ - from?: string | null; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description The topic. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow statistics */ + /** @description The topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStatsV1"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9389,15 +16269,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9427,565 +16298,492 @@ export interface operations { }; }; }; - verifyEmailAddress: { + v1UpdateTopic: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["VerifyEmail"]; - }; - }; - responses: { - /** @description Verification result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VerifyEmailResponse"]; - }; + "application/json": components["schemas"]["TopicUpdateV1"]; }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - }; - }; - listWebhooks: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; }; - requestBody?: never; responses: { - /** @description Webhook list */ + /** @description The updated topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookListResponse"]; + "application/json": components["schemas"]["TopicV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createWebhook: { + v1SetTopicSubscription: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateWebhook"]; + "application/json": components["schemas"]["TopicSubscribeV1"]; }; }; responses: { - /** @description Webhook created */ - 201: { + /** @description The resulting subscription */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCreateResponse"]; + "application/json": components["schemas"]["TopicSubscriptionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getWebhook: { + v1GetContactTopicPreferences: { parameters: { query?: never; header?: never; path: { + /** @description The contact. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook */ + /** @description The contact's preferences */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ContactTopicPreferencesV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteWebhook: { + v1ValidateEmails: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + }; + }; responses: { - /** @description Webhook deleted */ + /** @description One verdict per address, in the order they were given */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationBatchV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateWebhook: { + v1StartListValidationRun: { parameters: { query?: never; header?: never; path: { + /** @description The list to validate. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook updated */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The run, accepted and queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhookCalls: { + v1GetValidationRun: { parameters: { - query?: { - limit?: number; - cursor?: string; - }; + query?: never; header?: never; path: { + /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook call history */ + /** @description The run */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCallsListResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateWebhookSecret: { + v1ListValidationRunResults: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ + verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; + }; header?: never; path: { + /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Secret rotated */ + /** @description One page of results */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookRotateSecretResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationResultListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; diff --git a/src/types.ts b/src/types.ts index 9998d1b..c8fbce2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,7 +33,27 @@ export type BatchEntryResult = components["schemas"]["BatchEntryResult"]; export type EmailRecord = components["schemas"]["Email"]; export type EmailListResponse = components["schemas"]["EmailListResponse"]; -export type EmailGetResponse = components["schemas"]["EmailGetResponse"]; + +/** + * 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. + */ +export type EmailEvent = components["schemas"]["EmailEvent"]; + +/** An email together with its delivery history, oldest first. */ +export type EmailWithEvents = components["schemas"]["EmailWithEvents"]; + +/** + * A single email with no history — what `emails.cancelSchedule` resolves. + * + * Was `EmailGetResponse` in 1.0, which named the operation rather than the + * shape and was then reused by an operation that is not a GET. + */ +export type EmailResponse = components["schemas"]["EmailResponse"]; + +/** `emails.get` — one email plus its delivery events. */ +export type EmailDetailResponse = components["schemas"]["EmailDetailResponse"]; export type ListEmailsQuery = NonNullable; @@ -53,6 +73,8 @@ export type ListContactsQuery = NonNullable name}}`. Legacy dialect +// (envelope + camelCase), and gated by the same `templates:*` scopes as the +// templates that include them — a snippet is part of a template body, not a +// resource with an audience of its own. + +export type SnippetRecord = components["schemas"]["Snippet"]; +export type SnippetListResponse = components["schemas"]["SnippetListResponse"]; +export type CreateSnippetRequest = components["schemas"]["CreateSnippet"]; +export type UpdateSnippetRequest = components["schemas"]["UpdateSnippet"]; + +export type ListSnippetsQuery = NonNullable; + // ---------- Suppression ---------- export type SuppressionRecord = components["schemas"]["Suppression"]; @@ -142,8 +190,8 @@ export type CampaignV1 = components["schemas"]["CampaignV1"]; export type CampaignListV1 = components["schemas"]["CampaignV1List"]; export type CampaignDeletedV1 = components["schemas"]["CampaignV1Deleted"]; export type CampaignStatsV1 = components["schemas"]["CampaignV1Stats"]; -/** `type` defaults to `MARKETING` server-side, so it is optional here. */ -export type CreateCampaignV1Request = PartialKeys; +/** `email_category` defaults to `MARKETING` server-side, so it is optional here. */ +export type CreateCampaignV1Request = PartialKeys; export type UpdateCampaignV1Request = components["schemas"]["CampaignV1Update"]; export type SendCampaignV1Request = components["schemas"]["CampaignV1Send"]; @@ -224,5 +272,142 @@ export type ListTopCampaignsV1Query = NonNullable< paths["/api/v1/analytics/top-campaigns"]["get"]["parameters"]["query"] >; +// ---------- Contacts (v1) ---------- + +export type ContactV1 = components["schemas"]["ContactV1"]; +export type ContactListV1 = components["schemas"]["ContactV1List"]; +export type ContactDeletedV1 = components["schemas"]["ContactV1Deleted"]; +/** `subscribed` defaults to `true` server-side, so it is optional here. */ +export type CreateContactV1Request = PartialKeys; +export type UpdateContactV1Request = components["schemas"]["ContactV1Update"]; +/** Everything one contact has said they want, topic by topic. */ +export type ContactTopicPreferencesV1 = components["schemas"]["ContactTopicPreferencesV1"]; + +export type ListContactsV1Query = NonNullable; + +// ---------- Lists (v1) ---------- + +export type ListV1 = components["schemas"]["ListV1"]; +export type ListListV1 = components["schemas"]["ListV1List"]; +export type ListDeletedV1 = components["schemas"]["ListV1Deleted"]; +/** `double_opt_in` defaults to `false` server-side, so it is optional here. */ +export type CreateListV1Request = PartialKeys; +export type UpdateListV1Request = components["schemas"]["ListV1Update"]; + +export type ListListsV1Query = NonNullable; + +// ---------- Templates (v1) ---------- + +export type TemplateV1 = components["schemas"]["TemplateV1"]; +export type TemplateListV1 = components["schemas"]["TemplateV1List"]; +export type TemplateDeletedV1 = components["schemas"]["TemplateV1Deleted"]; +/** `email_category` defaults to `MARKETING` server-side, so it is optional here. */ +export type CreateTemplateV1Request = PartialKeys; +export type UpdateTemplateV1Request = components["schemas"]["TemplateV1Update"]; + +export type ListTemplatesV1Query = NonNullable; + +// ---------- Domains (v1) ---------- + +export type DomainV1 = components["schemas"]["DomainV1"]; +export type DomainListV1 = components["schemas"]["DomainV1List"]; +export type DomainDeletedV1 = components["schemas"]["DomainV1Deleted"]; +export type CreateDomainV1Request = components["schemas"]["DomainV1Create"]; + +export type ListDomainsV1Query = NonNullable; + +// ---------- Webhooks (v1) ---------- + +export type WebhookV1 = components["schemas"]["WebhookV1"]; +export type WebhookListV1 = components["schemas"]["WebhookV1List"]; +export type WebhookDeletedV1 = components["schemas"]["WebhookV1Deleted"]; +/** The create response, and the only time the signing secret is readable. */ +export type WebhookCreatedV1 = components["schemas"]["WebhookV1Created"]; +/** Rotation answers the new secret once, for the same reason. */ +export type WebhookSecretRotatedV1 = components["schemas"]["WebhookV1SecretRotated"]; +export type CreateWebhookV1Request = components["schemas"]["WebhookV1Create"]; +export type UpdateWebhookV1Request = components["schemas"]["WebhookV1Update"]; + +export type ListWebhooksV1Query = NonNullable; + +// ---------- Suppressions (v1) ---------- + +export type SuppressionV1 = components["schemas"]["SuppressionV1"]; +export type SuppressionListV1 = components["schemas"]["SuppressionV1List"]; +export type SuppressionDeletedV1 = components["schemas"]["SuppressionV1Deleted"]; +/** `reason` defaults to `MANUAL` server-side, so it is optional here. */ +export type CreateSuppressionV1Request = PartialKeys; + +export type ListSuppressionsV1Query = NonNullable; + +// ---------- Topics (v1) ---------- + +export type TopicV1 = components["schemas"]["TopicV1"]; +export type TopicListV1 = components["schemas"]["TopicListV1"]; +export type CreateTopicV1Request = components["schemas"]["TopicCreateV1"]; +export type UpdateTopicV1Request = components["schemas"]["TopicUpdateV1"]; +export type SetTopicSubscriptionV1Request = components["schemas"]["TopicSubscribeV1"]; +export type TopicSubscriptionV1 = components["schemas"]["TopicSubscriptionV1"]; +export type TopicSubscriptionStatusV1 = components["schemas"]["TopicSubscriptionStatusV1"]; + +export type ListTopicsV1Query = NonNullable; + +// ---------- Email validation (v1) ---------- + +export type ValidateEmailsV1Request = components["schemas"]["EmailValidationBatchRequestV1"]; +export type EmailValidationBatchV1 = components["schemas"]["EmailValidationBatchV1"]; +export type EmailValidationV1 = components["schemas"]["EmailValidationV1"]; +export type EmailValidationVerdictV1 = components["schemas"]["EmailValidationVerdictV1"]; +export type EmailValidationRunV1 = components["schemas"]["EmailValidationRunV1"]; +export type EmailValidationResultListV1 = components["schemas"]["EmailValidationResultListV1"]; +/** + * One address's verdict inside a run's results — a validation plus the + * `contact_id` it came from. The spec composes it inline rather than naming a + * component, so it is read off the page it appears in. + */ +export type EmailValidationResultV1 = EmailValidationResultListV1["data"][number]; + +export type ListValidationResultsV1Query = NonNullable< + paths["/api/v1/validation-runs/{id}/results"]["get"]["parameters"]["query"] +>; + +// ---------- Deliverability (v1) ---------- + +export type DeliverabilityDiagnosisV1 = components["schemas"]["DeliverabilityDiagnosisV1"]; +export type DeliverabilityFindingV1 = components["schemas"]["DeliverabilityFindingV1"]; +export type DeliverabilityFindingSeverityV1 = components["schemas"]["DeliverabilityFindingSeverityV1"]; +export type DeliverabilityIdentityV1 = components["schemas"]["DeliverabilityIdentityV1"]; +export type DeliverabilityRecentDeliveryV1 = components["schemas"]["DeliverabilityRecentDeliveryV1"]; +export type DeliverabilitySuppressionV1 = components["schemas"]["DeliverabilitySuppressionV1"]; +export type RecipientDomainStatsV1 = components["schemas"]["RecipientDomainStatsV1"]; +export type RecipientDomainStatsListV1 = components["schemas"]["RecipientDomainStatsV1List"]; +export type DmarcReportV1 = components["schemas"]["DmarcReportV1"]; +export type DmarcReportListV1 = components["schemas"]["DmarcReportV1List"]; + +export type DiagnoseDeliverabilityV1Query = NonNullable< + paths["/api/v1/deliverability/diagnose"]["get"]["parameters"]["query"] +>; +export type ListRecipientDomainStatsV1Query = NonNullable< + paths["/api/v1/deliverability/domains"]["get"]["parameters"]["query"] +>; +export type ListDmarcReportsV1Query = NonNullable; + +// ---------- Campaign failures (v1) ---------- + +export type CampaignFailureV1 = components["schemas"]["CampaignV1Failure"]; +export type CampaignFailureListV1 = components["schemas"]["CampaignV1FailureList"]; +export type CampaignRetryFailedV1 = components["schemas"]["CampaignV1RetryFailed"]; + +export type ListCampaignFailuresV1Query = NonNullable< + paths["/api/v1/campaigns/{id}/failures"]["get"]["parameters"]["query"] +>; + +// ---------- Workflow graph and lifecycle (v1) ---------- + +export type WorkflowGraphV1 = components["schemas"]["WorkflowGraphV1"]; +export type ReplaceWorkflowGraphV1Request = components["schemas"]["WorkflowGraphReplaceV1"]; +export type CloneWorkflowV1Request = components["schemas"]["WorkflowCloneV1"]; +export type WorkflowStateChangeV1 = components["schemas"]["WorkflowStateChangeV1"]; + // Re-export the raw shapes for advanced use. export type { components, operations, paths } from "./types.generated"; From 68a5af66e561d26ec8d776bb465b5084b720b52c Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Sat, 5 Sep 2026 04:11:43 -0400 Subject: [PATCH 2/7] feat!: one cursor dialect, and the narrowed email surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-vendors the released contract after the platform closed the second pagination dialect and narrowed the three /api/emails handlers. Pagination is uniform again. topics.list and validation.listResults took `cursor` and answered `cursor` where every other v1 list takes `after`; the platform collapsed that, so both now route through paginateCursor like every other collection instead of hand-rolling a loop. Breaking for a caller driving either of those two by hand — pass `after`, read `next_cursor`. Anyone using listAll or listResultsAll is unaffected. Both per-file page fixtures in the tests are gone in favour of helpers.cursorPage. A local fixture that describes a second dialect is exactly how the second dialect stayed invisible, so they delegate now rather than each restating the shape. The contract guard's cursor-envelope detection drops the second envelope it had to learn, and still keys on shape rather than an endpoint list, so a resource that reintroduces the dialect fails rather than being assumed away. emails.list and emails.cancelSchedule narrowed the same way emails.get did in the previous commit — the whole surface shared one leak and now shares one field list. cancelSchedule resolves EmailResponse, which the contract has always published for it. sentAt, deliveredAt and bouncedAt are newly declared on Email. The vendored spec now comes from apps/web/openapi/openapi.json, the RELEASED contract, which is the source scripts/sync-spec.mjs documents. It was taken from the wiki copy last round; the two are semantically identical and differ only in key order, which is why this diff is large and its real content is 66 added and 29 removed lines. Production was never a source. Do not publish before the platform deploy that ships this wire. --- CHANGELOG.md | 35 +- README.md | 29 +- dist/index.cjs | 48 +- dist/index.d.cts | 12857 ++++++------- dist/index.d.ts | 12857 ++++++------- dist/index.js | 48 +- openapi.json | 29561 +++++++++++++++-------------- src/__tests__/contract.test.ts | 12 +- src/__tests__/topics.test.ts | 30 +- src/__tests__/validation.test.ts | 29 +- src/resources/topics.ts | 25 +- src/resources/validation.ts | 26 +- src/types.generated.ts | 12831 ++++++------- 13 files changed, 34215 insertions(+), 34173 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 110a719..b01ad28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,24 @@ API that 1.0 did not. 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.cancelSchedule` 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 `cancelSchedule` 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`. + + `emails.cancelSchedule` resolves `EmailResponse`, which is what the contract has + always published for it — the SDK had typed 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 `Email`.** 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. They are + nullable, and null means the transition has not happened. + - **`EmailGetResponse` is gone, split in two.** It named the operation rather than the shape, and was then reused by an operation that is not a GET. There are now `EmailResponse` (a single email) and `EmailDetailResponse` (an email @@ -213,15 +231,14 @@ false })` stops new runs and leaves every in-flight contact walking the ### Notes -- **Pagination is not uniform, and the exception is worth knowing.** Most v1 - lists take `after` and answer `next_cursor`. **`topics.list` and - `validation.listResults` take `cursor` and answer `cursor`.** Both kinds are - forward-only opaque cursors and both stop on `has_more: false`; only the - parameter names differ. `topics.listAll` and `validation.listResultsAll` hide - it — they are hand-rolled rather than routed through `paginateCursor`, which - sends `after` and reads `next_cursor` and would otherwise re-fetch page one - forever. A caller driving pages by hand needs to know which endpoint speaks - which. +- **Pagination is uniform again.** Every v1 list takes `after` and answers + `next_cursor`. `topics.list` and `validation.listResults` 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 `paginateCursor` 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.listAll` or + `validation.listResultsAll` is unaffected. - **`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 diff --git a/README.md b/README.md index b6b8b26..f105a6a 100644 --- a/README.md +++ b/README.md @@ -764,8 +764,8 @@ against, and they carry `request_id` on every failure. ### Pagination -Most v1 lists take `limit` (1–100, default 20) and `after` (an opaque cursor from -the previous response's `next_cursor`). Page manually, or let the SDK do it — +Every v1 list takes `limit` (1–100, default 20) and `after` (an opaque cursor +from the previous response's `next_cursor`). Page manually, or let the SDK do it — each list has a companion `*All` async generator that walks the pages and yields individual items: @@ -790,26 +790,11 @@ The seventeen companions: `campaigns.listAll`, `campaigns.listFailuresAll`, `validation.listResultsAll`, `webhooks.listAllV1`, `workflows.listAll` and `workflows.listExecutionsAll`. -**Two endpoints name their cursor differently, and this is the one genuinely -surprising thing in the surface.** `topics.list` and `validation.listResults` -take `cursor` and answer `cursor`, where every other v1 list takes `after` and -answers `next_cursor`. Both kinds are forward-only opaque cursors and both stop -on `has_more: false`; only the parameter names differ. - -```ts -// after / next_cursor — every list except the two below. -let page = await sendly.templates.listV1({ limit: 50 }); -page = await sendly.templates.listV1({ limit: 50, after: page.next_cursor! }); - -// cursor / cursor — topics and validation results. -let topics = await sendly.topics.list({ limit: 50 }); -topics = await sendly.topics.list({ limit: 50, cursor: topics.cursor! }); -``` - -`topics.listAll` and `validation.listResultsAll` hide the difference — they are -hand-rolled for exactly this reason, because the shared `paginateCursor` helper -sends `after` and reads `next_cursor` and would re-fetch page one forever. Drive -pages by hand only if you know which of the two a given endpoint speaks. +Through 1.0 there were two dialects: `topics.list` and +`validation.listResults` 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`. Keep the filter and sort arguments **fixed for the whole walk** — the cursor encodes them, and changing them mid-pagination is answered with diff --git a/dist/index.cjs b/dist/index.cjs index d0f67e2..3c276fa 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -1495,7 +1495,7 @@ var TopicsResource = class { * is no delete — archiving is the retire button, because a topic is where * people's answers are recorded. {@link listAll} drives the loop for you. * - * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + * Paginated on `limit` + `after`, like every other v1 collection. */ async list(query) { return this.client.request({ @@ -1507,22 +1507,15 @@ var TopicsResource = class { /** * Iterate every topic across pages, yielding one topic at a time. * - * The walk is written out here rather than delegated to `paginateCursor` - * because this endpoint names its cursor `cursor` on both sides — the query - * parameter and the response field — where every other v1 list takes `after` - * and answers `next_cursor`. + * This used to be written out by hand: the endpoint named its cursor `cursor` + * on both sides where every other v1 list takes `after` and answers + * `next_cursor`, so the shared walker sent a parameter the route ignored and + * read a field it never returned — which silently re-fetched page one until + * `has_more` happened to be false. The route speaks the one dialect now, so + * this delegates like every other collection. */ async *listAll(query) { - let cursor = query?.cursor; - for (; ; ) { - const page = await this.list({ ...query, cursor }); - for (const topic of page.data) { - yield topic; - } - const next = page.cursor; - if (!page.has_more || next === null || next === cursor) return; - cursor = next; - } + yield* paginateCursor((after) => this.list({ ...query, after }), query?.after); } /** * Create a topic. @@ -1655,9 +1648,8 @@ var ValidationResource = class { * 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. * - * This list pages on `cursor`, not the `after` every other v1 collection - * takes, and its envelope carries the next page under `cursor` rather than - * `next_cursor`. {@link listResultsAll} drives that loop for you. + * Pages on `after` and answers `next_cursor`, like every other v1 + * collection. {@link listResultsAll} drives that loop for you. */ async listResults(id, query) { return this.client.request({ @@ -1669,23 +1661,13 @@ var ValidationResource = class { /** * Iterate every result across pages, yielding one address's verdict at a time. * - * Hand-rolled rather than routed through `paginateCursor`: the shared helper - * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on - * both sides, so the helper would send an ignored parameter and re-fetch page - * one forever. Stops on `has_more: false`, a null cursor, or a cursor the - * server repeats. + * This was hand-rolled through 1.0, because the endpoint spoke `cursor` on + * both sides while the shared helper 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. */ async *listResultsAll(id, query) { - let cursor = query?.cursor; - for (; ; ) { - const page = await this.listResults(id, { ...query, cursor }); - for (const result of page.data ?? []) { - yield result; - } - const next = page.cursor; - if (!page.has_more || next === null || next === void 0 || next === cursor) return; - cursor = next; - } + yield* paginateCursor((after) => this.listResults(id, { ...query, after }), query?.after); } }; diff --git a/dist/index.d.cts b/dist/index.d.cts index 7e54f05..a030b19 100644 --- a/dist/index.d.cts +++ b/dist/index.d.cts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ interface paths { - "/api/v1/campaigns": { + "/api/contacts": { parameters: { query?: never; header?: never; @@ -11,69 +11,55 @@ interface paths { cookie?: never; }; /** - * List campaigns - * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * List contacts + * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListCampaigns"]; + get: operations["listContacts"]; put?: never; /** - * Create a campaign - * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. - * - * The `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. - * - * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * Create a contact + * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateCampaign"]; + post: operations["createContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}": { + "/api/contacts/bulk": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve a campaign - * @description Fetch one campaign, including its materialized delivery counters. + * Bulk-create contacts + * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetCampaign"]; - put?: never; - post?: never; + post: operations["bulkCreateContacts"]; /** - * Delete a campaign - * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. + * Bulk-delete contacts + * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteCampaign"]; + delete: operations["bulkDeleteContacts"]; options?: never; head?: never; - /** - * Update a campaign - * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. - * - * Changing `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. - * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. - */ - patch: operations["v1UpdateCampaign"]; + patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/send": { + "/api/contacts/upsert": { parameters: { query?: never; header?: never; @@ -83,69 +69,115 @@ interface paths { get?: never; put?: never; /** - * Send or schedule a campaign - * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. - * - * **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. - * - * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * Create or update a contact by email + * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. * - * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1SendCampaign"]; + post: operations["upsertContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/cancel": { + "/api/contacts/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a contact + * @description Requires the `contacts:read` scope — View your contacts and their custom fields. + */ + get: operations["getContact"]; put?: never; + post?: never; /** - * Cancel a campaign - * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. + * Delete a contact + * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). * - * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + delete: operations["deleteContact"]; + options?: never; + head?: never; + /** + * Update a contact + * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CancelCampaign"]; + patch: operations["updateContact"]; + trace?: never; + }; + "/api/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List sending domains + * @description List all domains for the authenticated project. + * + * Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["listDomains"]; + put?: never; + /** + * Add a sending domain + * @description Register a new domain with SES and persist its DKIM tokens. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["addDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/pause": { + "/api/domains/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a sending domain + * @description Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["getDomain"]; put?: never; + post?: never; /** - * Pause a sending campaign - * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. + * Remove a sending domain + * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1PauseCampaign"]; - delete?: never; + delete: operations["deleteDomain"]; options?: never; head?: never; - patch?: never; + /** + * Assign a sending identity to a stream + * @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. + * + * Streams 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. + * + * At 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + patch: operations["assignDomainStream"]; trace?: never; }; - "/api/v1/campaigns/{id}/resume": { + "/api/domains/{id}/dodomain-session": { parameters: { query?: never; header?: never; @@ -155,19 +187,23 @@ interface paths { get?: never; put?: never; /** - * Resume a paused campaign - * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. + * Start guided DNS setup + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * The 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. + * + * `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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1ResumeCampaign"]; + post: operations["startDomainSetup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/stats": { + "/api/domains/{id}/verify": { parameters: { query?: never; header?: never; @@ -175,23 +211,27 @@ interface paths { cookie?: never; }; /** - * Retrieve campaign statistics - * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. - * - * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. + * Read SES verification status + * @description Read the current SES verification status without forcing a refresh. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1GetCampaignStats"]; + get: operations["getDomainVerification"]; put?: never; - post?: never; + /** + * Trigger SES verification + * @description Force a refresh of the domain's SES verification status. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["verifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/failures": { + "/api/emails": { parameters: { query?: never; header?: never; @@ -199,25 +239,27 @@ interface paths { cookie?: never; }; /** - * List a campaign's failed sends - * @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. - * - * `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. - * - * Cursor-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. + * List emails + * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["v1ListCampaignFailures"]; + get: operations["listEmails"]; put?: never; - post?: never; + /** + * Send a single transactional email + * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * + * Requires the `emails:send` scope — Send emails from your verified domains. + */ + post: operations["sendEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/retry-failed": { + "/api/emails/batch": { parameters: { query?: never; header?: never; @@ -227,23 +269,21 @@ interface paths { get?: never; put?: never; /** - * Retry a campaign's failed sends - * @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. - * - * The 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. + * Send a batch of emails + * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. * - * Only 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. + * The 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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `emails:send` scope — Send emails from your verified domains. */ - post: operations["v1RetryCampaignFailures"]; + post: operations["sendEmailBatch"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments": { + "/api/emails/{id}": { parameters: { query?: never; header?: never; @@ -251,91 +291,91 @@ interface paths { cookie?: never; }; /** - * List segments - * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * Get a single email + * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. * - * `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. + * `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. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["v1ListSegments"]; + get: operations["getEmail"]; put?: never; - /** - * Create a segment - * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. - * - * A `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. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. - */ - post: operations["v1CreateSegment"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments/{id}": { + "/api/emails/{id}/schedule": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Retrieve a segment - * @description Fetch one segment, including its saved `condition` and materialized `member_count`. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. - */ - get: operations["v1GetSegment"]; + get?: never; put?: never; post?: never; /** - * Delete a segment - * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * Cancel a scheduled (still-PENDING) email + * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. */ - delete: operations["v1DeleteSegment"]; + delete: operations["cancelScheduledEmail"]; options?: never; head?: never; + patch?: never; + trace?: never; + }; + "/api/lists/{id}/subscribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; /** - * Update a segment - * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * Subscribe a contact to a list + * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. * - * `type` is not accepted here — see the create operation. + * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * **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. + * + * `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. */ - patch: operations["v1UpdateSegment"]; + post: operations["subscribeToList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/v1/segments/{id}/contacts": { + "/api/lists/{id}/unsubscribe": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List the contacts in a segment - * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. - * - * Cursors 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. + * Unsubscribe a contact from a list + * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. */ - get: operations["v1ListSegmentContacts"]; - put?: never; - post?: never; + post: operations["unsubscribeFromList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows": { + "/api/mailboxes": { parameters: { query?: never; header?: never; @@ -343,31 +383,39 @@ interface paths { cookie?: never; }; /** - * List workflows - * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * List mailboxes + * @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. * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * This 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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1ListWorkflows"]; + get: operations["listMailboxes"]; put?: never; /** - * Create a workflow - * @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`. + * Create a mailbox + * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. * - * Pass `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. + * Three consequences worth knowing before you call it: * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * - **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. + * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. + * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * + * Retrying 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. + * + * `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. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1CreateWorkflow"]; + post: operations["createMailbox"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}": { + "/api/mailboxes/{id}": { parameters: { query?: never; header?: never; @@ -375,37 +423,29 @@ interface paths { cookie?: never; }; /** - * Retrieve a workflow - * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. + * Get a mailbox + * @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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1GetWorkflow"]; + get: operations["getMailbox"]; put?: never; post?: never; /** - * Delete a workflow - * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. + * Delete a mailbox + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires an admin of the project. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - delete: operations["v1DeleteWorkflow"]; + delete: operations["deleteMailbox"]; options?: never; head?: never; - /** - * Update a workflow - * @description Sparse update — omitted fields are left unchanged. - * - * Two 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. - * - * `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. - * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. - */ - patch: operations["v1UpdateWorkflow"]; + patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/executions": { + "/api/mailboxes/{id}/app-passwords": { parameters: { query?: never; header?: never; @@ -413,29 +453,33 @@ interface paths { cookie?: never; }; /** - * List a workflow's executions - * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. + * List a mailbox's app passwords + * @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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1ListWorkflowExecutions"]; + get: operations["listAppPasswords"]; put?: never; /** - * Start a workflow for a contact - * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. + * Create an app password + * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. * - * 409 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. + * **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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * That 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. + * + * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1StartWorkflowExecution"]; + post: operations["createAppPassword"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/executions/{execution_id}/cancel": { + "/api/mailboxes/{id}/app-passwords/{passwordId}": { parameters: { query?: never; header?: never; @@ -444,106 +488,119 @@ interface paths { }; get?: never; put?: never; + post?: never; /** - * Cancel a workflow execution - * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. + * Revoke an app password + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires an admin of the project. An API key is refused with 401. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1CancelWorkflowExecution"]; - delete?: never; + delete: operations["revokeAppPassword"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/stats": { + "/api/mailboxes/{id}/drafts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve workflow statistics - * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. + * Draft a message with AI + * @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. * - * The 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. + * **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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * That 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. + * + * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * + * Drafting is capped at 120 requests per hour per project. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1GetWorkflowStats"]; - put?: never; - post?: never; + post: operations["draftMailboxMessage"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/graph": { + "/api/mailboxes/{id}/messages": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve a workflow's step graph - * @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. + * Send a message from a mailbox + * @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. * - * A 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. + * **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. * - * The 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. + * **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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. - */ - get: operations["v1GetWorkflowGraph"]; - /** - * Replace a workflow's step graph - * @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. + * Bcc 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. * - * A 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. + * Refusals worth handling by name: * - * Refused 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. + * - `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. + * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. + * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. + * - `429` — a mailbox may send 60 messages an hour through this endpoint. * - * The 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. + * The message is stored as a new conversation on the mailbox, so the reply threads onto it. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. */ - put: operations["v1ReplaceWorkflowGraph"]; - post?: never; + post: operations["sendMailboxMessage"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/clone": { + "/api/projects/{id}/api-keys": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List API keys for a project + * @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. + * + * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + */ + get: operations["listApiKeys"]; put?: never; /** - * Clone a workflow - * @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. + * Create an API key + * @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. * - * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. + * **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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1CloneWorkflow"]; + post: operations["createApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/pause": { + "/api/projects/{id}/api-keys/{keyId}": { parameters: { query?: never; header?: never; @@ -552,24 +609,20 @@ interface paths { }; get?: never; put?: never; + post?: never; /** - * Pause a workflow and cancel its running executions - * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. - * - * `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. - * - * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. + * Revoke an API key + * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1PauseWorkflow"]; - delete?: never; + delete: operations["revokeApiKey"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/resume": { + "/api/projects/{id}/api-keys/{keyId}/rotate": { parameters: { query?: never; header?: never; @@ -579,79 +632,77 @@ interface paths { get?: never; put?: never; /** - * Resume a paused workflow - * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. - * - * Refused 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. + * Rotate an API key's secret + * @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`). * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1ResumeWorkflow"]; + post: operations["rotateApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails": { + "/api/snippets": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a transactional email - * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. - * - * This 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. - * - * Exactly 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. - * - * `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. + * List snippets + * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. * - * An 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. + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listSnippets"]; + put?: never; + /** + * Create a snippet + * @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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1SendEmail"]; + post: operations["createSnippet"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails/test": { + "/api/snippets/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a snippet + * @description Requires the `templates:read` scope — View your email templates. + */ + get: operations["getSnippet"]; put?: never; + post?: never; /** - * Send a sandbox test email - * @description Prove that sending works — before any domain, DNS record or verification exists. - * - * The 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. - * - * That 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. - * - * Sandbox 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. + * Delete a snippet + * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. * - * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1SendTestEmail"]; - delete?: never; + delete: operations["deleteSnippet"]; options?: never; head?: never; - patch?: never; + /** + * Update a snippet + * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateSnippet"]; trace?: never; }; - "/api/emails": { + "/api/suppression": { parameters: { query?: never; header?: never; @@ -659,27 +710,27 @@ interface paths { cookie?: never; }; /** - * List emails - * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. + * List suppressed emails + * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `suppression:read` scope — View the addresses on your suppression list. */ - get: operations["listEmails"]; + get: operations["listSuppressions"]; put?: never; /** - * Send a single transactional email - * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * Manually add an email to the suppression list + * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. */ - post: operations["sendEmail"]; + post: operations["addSuppression"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}": { + "/api/suppression/{email}": { parameters: { query?: never; header?: never; @@ -687,95 +738,109 @@ interface paths { cookie?: never; }; /** - * Get a single email - * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. - * - * `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. + * Check whether an email is suppressed + * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `suppression:read` scope — View the addresses on your suppression list. */ - get: operations["getEmail"]; + get: operations["checkSuppression"]; put?: never; post?: never; - delete?: never; + /** + * Remove an email from the suppression list + * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["removeSuppression"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/batch": { + "/api/templates": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a batch of emails - * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. + * List templates + * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. * - * The 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. + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listTemplates"]; + put?: never; + /** + * Create a template + * @description Create a new email template. The `from` domain must already be verified for the project. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["sendEmailBatch"]; + post: operations["createTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}/schedule": { + "/api/templates/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a template + * @description Requires the `templates:read` scope — View your email templates. + */ + get: operations["getTemplate"]; put?: never; post?: never; /** - * Cancel a scheduled (still-PENDING) email - * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. + * Delete a template + * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["cancelScheduledEmail"]; + delete: operations["deleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Update one or more fields. If `from` changes, the new domain must already be verified. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateTemplate"]; trace?: never; }; - "/api/contacts": { + "/api/track": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List contacts - * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. - * - * Requires the `contacts:read` scope — View your contacts and their custom fields. - */ - get: operations["listContacts"]; + get?: never; put?: never; /** - * Create a contact - * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. + * Track a custom event for a contact + * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `events:write` scope — Record custom events for your contacts. */ - post: operations["createContact"]; + post: operations["trackEvent"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/upsert": { + "/api/users/me/projects": { parameters: { query?: never; header?: never; @@ -785,47 +850,47 @@ interface paths { get?: never; put?: never; /** - * Create or update a contact by email - * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. + * Create a project + * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Preconditions 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. + * + * Requires the `projects:write` scope — Create new projects on your account. */ - post: operations["upsertContact"]; + post: operations["createProject"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/bulk": { + "/api/v1/analytics/campaigns": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Bulk-create contacts - * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. + * Retrieve campaign totals and engagement + * @description Campaign counts plus average open and click rates. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - post: operations["bulkCreateContacts"]; - /** - * Bulk-delete contacts - * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. + * `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. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - delete: operations["bulkDeleteContacts"]; + get: operations["v1GetCampaignAnalytics"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/{id}": { + "/api/v1/analytics/timeseries": { parameters: { query?: never; header?: never; @@ -833,31 +898,23 @@ interface paths { cookie?: never; }; /** - * Get a contact - * @description Requires the `contacts:read` scope — View your contacts and their custom fields. + * Retrieve the daily email time series + * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - get: operations["getContact"]; + get: operations["v1GetAnalyticsTimeseries"]; put?: never; post?: never; - /** - * Delete a contact - * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - delete: operations["deleteContact"]; + delete?: never; options?: never; head?: never; - /** - * Update a contact - * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - patch: operations["updateContact"]; + patch?: never; trace?: never; }; - "/api/v1/contacts": { + "/api/v1/analytics/top-campaigns": { parameters: { query?: never; header?: never; @@ -865,29 +922,23 @@ interface paths { cookie?: never; }; /** - * List contacts - * @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. + * List the best-performing campaigns + * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. * - * A 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. + * `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. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - get: operations["v1ListContacts"]; + get: operations["v1ListTopCampaigns"]; put?: never; - /** - * Create a contact - * @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. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - post: operations["v1CreateContact"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/contacts/{id}": { + "/api/v1/campaigns": { parameters: { query?: never; header?: never; @@ -895,63 +946,69 @@ interface paths { cookie?: never; }; /** - * Retrieve a contact - * @description Fetch one contact by id. + * List campaigns + * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - get: operations["v1GetContact"]; + get: operations["v1ListCampaigns"]; put?: never; - post?: never; /** - * Delete a contact - * @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. + * Create a campaign + * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - delete: operations["v1DeleteContact"]; - options?: never; - head?: never; - /** - * Update a contact - * @description Partial update. Omitted fields are left alone. - * - * `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. + * The `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. * - * `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. + * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - patch: operations["v1UpdateContact"]; + post: operations["v1CreateCampaign"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/lists/{id}/subscribe": { + "/api/v1/campaigns/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Subscribe a contact to a list - * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. - * - * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. + * Retrieve a campaign + * @description Fetch one campaign, including its materialized delivery counters. * - * **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. + * Requires the `campaigns:read` scope — View your campaigns and their performance. + */ + get: operations["v1GetCampaign"]; + put?: never; + post?: never; + /** + * Delete a campaign + * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. * - * `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. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["subscribeToList"]; - delete?: never; + delete: operations["v1DeleteCampaign"]; options?: never; head?: never; - patch?: never; + /** + * Update a campaign + * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * + * Changing `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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + */ + patch: operations["v1UpdateCampaign"]; trace?: never; }; - "/api/lists/{id}/unsubscribe": { + "/api/v1/campaigns/{id}/cancel": { parameters: { query?: never; header?: never; @@ -961,19 +1018,21 @@ interface paths { get?: never; put?: never; /** - * Unsubscribe a contact from a list - * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. + * Cancel a campaign + * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. * - * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. + * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["unsubscribeFromList"]; + post: operations["v1CancelCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/lists": { + "/api/v1/campaigns/{id}/failures": { parameters: { query?: never; header?: never; @@ -981,189 +1040,145 @@ interface paths { cookie?: never; }; /** - * List subscriber lists - * @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. - * - * `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. - * - * Requires the `lists:read` scope — View your subscriber lists and who is on them. - */ - get: operations["v1ListLists"]; - put?: never; - /** - * Create a subscriber list - * @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`. + * List a campaign's failed sends + * @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. * - * **`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. + * `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. * - * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. + * Cursor-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. * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["v1CreateList"]; + get: operations["v1ListCampaignFailures"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/lists/{id}": { + "/api/v1/campaigns/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Retrieve a subscriber list - * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. - * - * Requires the `lists:read` scope — View your subscriber lists and who is on them. - */ - get: operations["v1GetList"]; + get?: never; put?: never; - post?: never; /** - * Delete a subscriber list - * @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. + * Pause a sending campaign + * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["v1DeleteList"]; + post: operations["v1PauseCampaign"]; + delete?: never; options?: never; head?: never; - /** - * Update a subscriber list - * @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. - * - * **`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. - * - * Turning `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. - * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. - */ - patch: operations["v1UpdateList"]; + patch?: never; trace?: never; }; - "/api/domains": { + "/api/v1/campaigns/{id}/resume": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List sending domains - * @description List all domains for the authenticated project. - * - * Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["listDomains"]; + get?: never; put?: never; /** - * Add a sending domain - * @description Register a new domain with SES and persist its DKIM tokens. + * Resume a paused campaign + * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["addDomain"]; + post: operations["v1ResumeCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}": { + "/api/v1/campaigns/{id}/retry-failed": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a sending domain - * @description Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["getDomain"]; + get?: never; put?: never; - post?: never; - /** - * Remove a sending domain - * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. - * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - delete: operations["deleteDomain"]; - options?: never; - head?: never; /** - * Assign a sending identity to a stream - * @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. + * Retry a campaign's failed sends + * @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. * - * Streams 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. + * The 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. * - * At 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. + * Only 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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - patch: operations["assignDomainStream"]; + post: operations["v1RetryCampaignFailures"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/domains/{id}/verify": { + "/api/v1/campaigns/{id}/send": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Read SES verification status - * @description Read the current SES verification status without forcing a refresh. - * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - get: operations["getDomainVerification"]; + get?: never; put?: never; /** - * Trigger SES verification - * @description Force a refresh of the domain's SES verification status. + * Send or schedule a campaign + * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * **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. + * + * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * + * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. */ - post: operations["verifyDomain"]; + post: operations["v1SendCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/dodomain-session": { + "/api/v1/campaigns/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Start guided DNS setup - * @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. - * - * The 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. + * Retrieve campaign statistics + * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. * - * `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. + * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["startDomainSetup"]; + get: operations["v1GetCampaignStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/domains": { + "/api/v1/contacts": { parameters: { query?: never; header?: never; @@ -1171,35 +1186,29 @@ interface paths { cookie?: never; }; /** - * List sending domains - * @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. + * List contacts + * @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. * - * `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. + * A 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. * - * Requires the `domains:read` scope — View your sending domains and their verification status. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListDomains"]; + get: operations["v1ListContacts"]; put?: never; /** - * Add a sending domain - * @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. - * - * `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. - * - * `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. - * - * A 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. + * Create a contact + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateDomain"]; + post: operations["v1CreateContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/domains/{id}": { + "/api/v1/contacts/{id}": { parameters: { query?: never; header?: never; @@ -1207,55 +1216,63 @@ interface paths { cookie?: never; }; /** - * Retrieve a sending domain - * @description Fetch one sending domain by id. + * Retrieve a contact + * @description Fetch one contact by id. * - * Requires the `domains:read` scope — View your sending domains and their verification status. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1GetDomain"]; + get: operations["v1GetContact"]; put?: never; post?: never; /** - * Remove a sending domain - * @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. - * - * The 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. + * Delete a contact + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteDomain"]; + delete: operations["v1DeleteContact"]; options?: never; head?: never; - patch?: never; + /** + * Update a contact + * @description Partial update. Omitted fields are left alone. + * + * `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. + * + * `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. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + patch: operations["v1UpdateContact"]; trace?: never; }; - "/api/v1/domains/{id}/verify": { + "/api/v1/contacts/{id}/topics": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Refresh a sending domain's verification state - * @description Re-read this domain's state from SES and DNS and return the refreshed document. + * Get a contact's topic preferences + * @description Everything this contact has said they want, as the send path reads it. * - * This 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. + * `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. * - * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * The 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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - post: operations["v1VerifyDomain"]; + get: operations["v1GetContactTopicPreferences"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates": { + "/api/v1/deliverability/diagnose": { parameters: { query?: never; header?: never; @@ -1263,27 +1280,25 @@ interface paths { cookie?: never; }; /** - * List templates - * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. + * Diagnose why mail from a domain is not arriving + * @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. * - * Requires the `templates:read` scope — View your email templates. - */ - get: operations["listTemplates"]; - put?: never; - /** - * Create a template - * @description Create a new email template. The `from` domain must already be verified for the project. + * Everything 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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * `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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - post: operations["createTemplate"]; + get: operations["v1DiagnoseDeliverability"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates/{id}": { + "/api/v1/deliverability/dmarc": { parameters: { query?: never; header?: never; @@ -1291,31 +1306,27 @@ interface paths { cookie?: never; }; /** - * Get a template - * @description Requires the `templates:read` scope — View your email templates. + * DMARC aggregate reports for your domains + * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * + * The 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. + * + * `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. + * + * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - get: operations["getTemplate"]; + get: operations["v1ListDmarcReports"]; put?: never; post?: never; - /** - * Delete a template - * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - delete: operations["deleteTemplate"]; + delete?: never; options?: never; head?: never; - /** - * Update a template - * @description Update one or more fields. If `from` changes, the new domain must already be verified. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateTemplate"]; + patch?: never; trace?: never; }; - "/api/v1/templates": { + "/api/v1/deliverability/domains": { parameters: { query?: never; header?: never; @@ -1323,31 +1334,25 @@ interface paths { cookie?: never; }; /** - * List templates - * @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. + * Delivery outcomes per recipient domain + * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. * - * `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. + * This 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. * - * A 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. + * The 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. * - * Requires the `templates:read` scope — View your email templates. + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - get: operations["v1ListTemplates"]; + get: operations["v1ListRecipientDomainStats"]; put?: never; - /** - * Create a template - * @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. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - post: operations["v1CreateTemplate"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/templates/{id}": { + "/api/v1/domains": { parameters: { query?: never; header?: never; @@ -1355,37 +1360,35 @@ interface paths { cookie?: never; }; /** - * Retrieve a template - * @description Fetch one template by id. + * List sending domains + * @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. * - * Requires the `templates:read` scope — View your email templates. + * `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. + * + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetTemplate"]; + get: operations["v1ListDomains"]; put?: never; - post?: never; /** - * Delete a template - * @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. + * Add a sending domain + * @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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - delete: operations["v1DeleteTemplate"]; - options?: never; - head?: never; - /** - * Update a template - * @description Partial update. Omitted fields are left alone. + * `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. * - * Changing `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. + * `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. * - * A `from` supplied here is verified before anything is written, on the same terms as create. + * A 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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - patch: operations["v1UpdateTemplate"]; + post: operations["v1CreateDomain"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/snippets": { + "/api/v1/domains/{id}": { parameters: { query?: never; header?: never; @@ -1393,115 +1396,113 @@ interface paths { cookie?: never; }; /** - * List snippets - * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. + * Retrieve a sending domain + * @description Fetch one sending domain by id. * - * Requires the `templates:read` scope — View your email templates. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["listSnippets"]; + get: operations["v1GetDomain"]; put?: never; + post?: never; /** - * Create a snippet - * @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. + * Remove a sending domain + * @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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * The 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["createSnippet"]; - delete?: never; + delete: operations["v1DeleteDomain"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/snippets/{id}": { + "/api/v1/domains/{id}/verify": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a snippet - * @description Requires the `templates:read` scope — View your email templates. - */ - get: operations["getSnippet"]; + get?: never; put?: never; - post?: never; /** - * Delete a snippet - * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. + * Refresh a sending domain's verification state + * @description Re-read this domain's state from SES and DNS and return the refreshed document. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * This 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. + * + * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - delete: operations["deleteSnippet"]; + post: operations["v1VerifyDomain"]; + delete?: never; options?: never; head?: never; - /** - * Update a snippet - * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateSnippet"]; + patch?: never; trace?: never; }; - "/api/webhooks": { + "/api/v1/email-validations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List user webhooks - * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). - * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. - */ - get: operations["listWebhooks"]; + get?: never; put?: never; /** - * Create a webhook - * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. + * Validate a batch of email addresses + * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * The 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. + * + * `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. + * + * The 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. */ - post: operations["createWebhook"]; + post: operations["v1ValidateEmails"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}": { + "/api/v1/emails": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a webhook - * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. - */ - get: operations["getWebhook"]; + get?: never; put?: never; - post?: never; /** - * Delete a webhook - * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * Send a transactional email + * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * This 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. + * + * Exactly 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. + * + * `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. + * + * An 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["deleteWebhook"]; + post: operations["v1SendEmail"]; + delete?: never; options?: never; head?: never; - /** - * Update a webhook - * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - patch: operations["updateWebhook"]; + patch?: never; trace?: never; }; - "/api/webhooks/{id}/rotate-secret": { + "/api/v1/emails/test": { parameters: { query?: never; header?: never; @@ -1511,19 +1512,25 @@ interface paths { get?: never; put?: never; /** - * Rotate the webhook signing secret - * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * Send a sandbox test email + * @description Prove that sending works — before any domain, DNS record or verification exists. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * The 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. + * + * That 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. + * + * Sandbox 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. + * + * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. */ - post: operations["rotateWebhookSecret"]; + post: operations["v1SendTestEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}/calls": { + "/api/v1/events": { parameters: { query?: never; header?: never; @@ -1531,21 +1538,37 @@ interface paths { cookie?: never; }; /** - * List recent webhook calls - * @description Cursor-paginated list of recent delivery attempts for a single webhook. + * List events + * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * A 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["listWebhookCalls"]; + get: operations["v1ListEvents"]; put?: never; - post?: never; + /** + * Record an event + * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. + * + * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * + * Reserved 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. + * + * This 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. + * + * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["v1TrackEvent"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/webhooks": { + "/api/v1/events/names": { parameters: { query?: never; header?: never; @@ -1553,31 +1576,21 @@ interface paths { cookie?: never; }; /** - * List webhooks - * @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. - * - * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. + * List event names + * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["v1ListWebhooks"]; + get: operations["v1ListEventNames"]; put?: never; - /** - * Create a webhook - * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. - * - * The 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. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - post: operations["v1CreateWebhook"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/webhooks/{id}": { + "/api/v1/events/stats": { parameters: { query?: never; header?: never; @@ -1585,63 +1598,57 @@ interface paths { cookie?: never; }; /** - * Retrieve a webhook - * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. + * Retrieve event counts + * @description Per-name event counts over a bounded window, most frequent first. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * The 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["v1GetWebhook"]; + get: operations["v1GetEventStats"]; put?: never; post?: never; - /** - * Delete a webhook - * @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. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - delete: operations["v1DeleteWebhook"]; + delete?: never; options?: never; head?: never; - /** - * Update a webhook - * @description Partial update. 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 part of this response. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - patch: operations["v1UpdateWebhook"]; + patch?: never; trace?: never; }; - "/api/v1/webhooks/{id}/rotate-secret": { + "/api/v1/lists": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List subscriber lists + * @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. + * + * `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. + * + * Requires the `lists:read` scope — View your subscriber lists and who is on them. + */ + get: operations["v1ListLists"]; put?: never; /** - * Rotate a webhook signing secret - * @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. + * Create a subscriber list + * @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`. * - * Rotation 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. + * **`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. * - * `url`, `event_types` and `status` are unchanged. + * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["v1RotateWebhookSecret"]; + post: operations["v1CreateList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys": { + "/api/v1/lists/{id}": { parameters: { query?: never; header?: never; @@ -1649,29 +1656,37 @@ interface paths { cookie?: never; }; /** - * List API keys for a project - * @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. + * Retrieve a subscriber list + * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. * - * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - get: operations["listApiKeys"]; + get: operations["v1GetList"]; put?: never; + post?: never; /** - * Create an API key - * @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. - * - * **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. + * Delete a subscriber list + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["createApiKey"]; - delete?: never; + delete: operations["v1DeleteList"]; options?: never; head?: never; - patch?: never; + /** + * Update a subscriber list + * @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. + * + * **`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. + * + * Turning `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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + patch: operations["v1UpdateList"]; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}/rotate": { + "/api/v1/lists/{id}/validation-runs": { parameters: { query?: never; header?: never; @@ -1681,41 +1696,49 @@ interface paths { get?: never; put?: never; /** - * Rotate an API key's secret - * @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`). + * Validate every address on a list + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * This 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`. + * + * A 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. */ - post: operations["rotateApiKey"]; + post: operations["v1StartListValidationRun"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}": { + "/api/v1/projects": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Revoke an API key - * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * Retrieve the authenticated project + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * `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). + * + * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * + * Requires the `projects:read` scope — View your projects and their settings. */ - delete: operations["revokeApiKey"]; + get: operations["v1GetProject"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression": { + "/api/v1/segments": { parameters: { query?: never; header?: never; @@ -1723,27 +1746,31 @@ interface paths { cookie?: never; }; /** - * List suppressed emails - * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. + * List segments + * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * `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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["listSuppressions"]; + get: operations["v1ListSegments"]; put?: never; /** - * Manually add an email to the suppression list - * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * Create a segment + * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * A `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. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - post: operations["addSuppression"]; + post: operations["v1CreateSegment"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression/{email}": { + "/api/v1/segments/{id}": { parameters: { query?: never; header?: never; @@ -1751,27 +1778,35 @@ interface paths { cookie?: never; }; /** - * Check whether an email is suppressed - * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * Retrieve a segment + * @description Fetch one segment, including its saved `condition` and materialized `member_count`. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["checkSuppression"]; + get: operations["v1GetSegment"]; put?: never; post?: never; /** - * Remove an email from the suppression list - * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * Delete a segment + * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - delete: operations["removeSuppression"]; + delete: operations["v1DeleteSegment"]; options?: never; head?: never; - patch?: never; + /** + * Update a segment + * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * + * `type` is not accepted here — see the create operation. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + patch: operations["v1UpdateSegment"]; trace?: never; }; - "/api/v1/suppressions": { + "/api/v1/segments/{id}/contacts": { parameters: { query?: never; header?: never; @@ -1779,22 +1814,46 @@ interface paths { cookie?: never; }; /** - * List suppressed addresses - * @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. + * List the contacts in a segment + * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. * - * A 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. + * Cursors 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. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["v1ListSuppressions"]; + get: operations["v1ListSegmentContacts"]; put?: never; - /** - * Suppress an address - * @description Add an address to this project's suppression list, so no further send reaches it. - * - * Idempotent: 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. - * - * `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. + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List suppressed addresses + * @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. + * + * A 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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1ListSuppressions"]; + put?: never; + /** + * Suppress an address + * @description Add an address to this project's suppression list, so no further send reaches it. + * + * Idempotent: 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. + * + * `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. * * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. */ @@ -1841,29 +1900,39 @@ interface paths { patch?: never; trace?: never; }; - "/api/track": { + "/api/v1/templates": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List templates + * @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. + * + * `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. + * + * A 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. + * + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["v1ListTemplates"]; put?: never; /** - * Track a custom event for a contact - * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. + * Create a template + * @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. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["trackEvent"]; + post: operations["v1CreateTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events": { + "/api/v1/templates/{id}": { parameters: { query?: never; header?: never; @@ -1871,37 +1940,37 @@ interface paths { cookie?: never; }; /** - * List events - * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. - * - * A 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. + * Retrieve a template + * @description Fetch one template by id. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListEvents"]; + get: operations["v1GetTemplate"]; put?: never; + post?: never; /** - * Record an event - * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. - * - * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * Delete a template + * @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. * - * Reserved 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. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + delete: operations["v1DeleteTemplate"]; + options?: never; + head?: never; + /** + * Update a template + * @description Partial update. Omitted fields are left alone. * - * This 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. + * Changing `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. * - * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * A `from` supplied here is verified before anything is written, on the same terms as create. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1TrackEvent"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["v1UpdateTemplate"]; trace?: never; }; - "/api/v1/events/names": { + "/api/v1/topics": { parameters: { query?: never; header?: never; @@ -1909,45 +1978,33 @@ interface paths { cookie?: never; }; /** - * List event names - * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * List topics + * @description The subjects this project mails about, cursor-paginated and newest first. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Archived 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. + * + * `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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - get: operations["v1ListEventNames"]; + get: operations["v1ListTopics"]; put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/events/stats": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; /** - * Retrieve event counts - * @description Per-name event counts over a bounded window, most frequent first. + * Create a topic + * @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. * - * The 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. + * `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`. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. */ - get: operations["v1GetEventStats"]; - put?: never; - post?: never; + post: operations["v1CreateTopic"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/timeseries": { + "/api/v1/topics/{id}": { parameters: { query?: never; header?: never; @@ -1955,49 +2012,53 @@ interface paths { cookie?: never; }; /** - * Retrieve the daily email time series - * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. - * - * `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. - * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Retrieve a topic + * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - get: operations["v1GetAnalyticsTimeseries"]; + get: operations["v1GetTopic"]; put?: never; post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** + * Update a topic + * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + patch: operations["v1UpdateTopic"]; trace?: never; }; - "/api/v1/analytics/campaigns": { + "/api/v1/topics/{id}/subscriptions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve campaign totals and engagement - * @description Campaign counts plus average open and click rates. + * Subscribe or unsubscribe a contact from a topic + * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. * - * `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. + * `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. * - * `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. + * `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. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. */ - get: operations["v1GetCampaignAnalytics"]; - put?: never; - post?: never; + post: operations["v1SetTopicSubscription"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/top-campaigns": { + "/api/v1/usage": { parameters: { query?: never; header?: never; @@ -2005,14 +2066,19 @@ interface paths { cookie?: never; }; /** - * List the best-performing campaigns - * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. + * Retrieve current usage and limits + * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. * - * `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. + * Every 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. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Two caveats worth reading before you alert on these numbers: + * + * - 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. + * - `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`. + * + * Requires the `usage:read` scope — View your usage totals and billing limits. */ - get: operations["v1ListTopCampaigns"]; + get: operations["v1GetUsage"]; put?: never; post?: never; delete?: never; @@ -2021,7 +2087,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/diagnose": { + "/api/v1/validation-runs/{id}": { parameters: { query?: never; header?: never; @@ -2029,16 +2095,14 @@ interface paths { cookie?: never; }; /** - * Diagnose why mail from a domain is not arriving - * @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. - * - * Everything 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. + * Retrieve a validation run + * @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. * - * `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. + * There 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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `validation:read` scope — View your email validation runs and their results. */ - get: operations["v1DiagnoseDeliverability"]; + get: operations["v1GetValidationRun"]; put?: never; post?: never; delete?: never; @@ -2047,7 +2111,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/domains": { + "/api/v1/validation-runs/{id}/results": { parameters: { query?: never; header?: never; @@ -2055,16 +2119,16 @@ interface paths { cookie?: never; }; /** - * Delivery outcomes per recipient domain - * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. + * List a validation run's results + * @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. * - * This 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. + * No 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. * - * The 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. + * `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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `validation:read` scope — View your email validation runs and their results. */ - get: operations["v1ListRecipientDomainStats"]; + get: operations["v1ListValidationRunResults"]; put?: never; post?: never; delete?: never; @@ -2073,7 +2137,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/dmarc": { + "/api/v1/webhooks": { parameters: { query?: never; header?: never; @@ -2081,27 +2145,31 @@ interface paths { cookie?: never; }; /** - * DMARC aggregate reports for your domains - * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * List webhooks + * @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. * - * The 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. + * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. * - * `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. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + */ + get: operations["v1ListWebhooks"]; + put?: never; + /** + * Create a webhook + * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. * - * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * The 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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1ListDmarcReports"]; - put?: never; - post?: never; + post: operations["v1CreateWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/usage": { + "/api/v1/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -2109,54 +2177,63 @@ interface paths { cookie?: never; }; /** - * Retrieve current usage and limits - * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. - * - * Every 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. - * - * Two caveats worth reading before you alert on these numbers: - * - * - 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. - * - `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`. + * Retrieve a webhook + * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. * - * Requires the `usage:read` scope — View your usage totals and billing limits. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetUsage"]; + get: operations["v1GetWebhook"]; put?: never; post?: never; - delete?: never; + /** + * Delete a webhook + * @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. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + delete: operations["v1DeleteWebhook"]; options?: never; head?: never; - patch?: never; + /** + * Update a webhook + * @description Partial update. 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 part of this response. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + patch: operations["v1UpdateWebhook"]; trace?: never; }; - "/api/v1/projects": { + "/api/v1/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve the authenticated project - * @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. + * Rotate a webhook signing secret + * @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. * - * `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). + * Rotation 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. * - * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * `url`, `event_types` and `status` are unchanged. * - * Requires the `projects:read` scope — View your projects and their settings. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1GetProject"]; - put?: never; - post?: never; + post: operations["v1RotateWebhookSecret"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes": { + "/api/v1/workflows": { parameters: { query?: never; header?: never; @@ -2164,39 +2241,53 @@ interface paths { cookie?: never; }; /** - * List mailboxes - * @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. + * List workflows + * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * This 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. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listMailboxes"]; + get: operations["v1ListWorkflows"]; put?: never; /** - * Create a mailbox - * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. - * - * Three consequences worth knowing before you call it: - * - * - **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. - * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. - * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * Create a workflow + * @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`. * - * Retrying 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. + * Pass `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. * - * `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. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + post: operations["v1CreateWorkflow"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/executions/{execution_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel a workflow execution + * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createMailbox"]; + post: operations["v1CancelWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}": { + "/api/v1/workflows/{id}": { parameters: { query?: never; header?: never; @@ -2204,29 +2295,37 @@ interface paths { cookie?: never; }; /** - * Get a mailbox - * @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. + * Retrieve a workflow + * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["getMailbox"]; + get: operations["v1GetWorkflow"]; put?: never; post?: never; /** - * Delete a mailbox - * @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. - * - * Requires an admin of the project. + * Delete a workflow + * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["deleteMailbox"]; + delete: operations["v1DeleteWorkflow"]; options?: never; head?: never; - patch?: never; + /** + * Update a workflow + * @description Sparse update — omitted fields are left unchanged. + * + * Two 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. + * + * `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + patch: operations["v1UpdateWorkflow"]; trace?: never; }; - "/api/mailboxes/{id}/drafts": { + "/api/v1/workflows/{id}/clone": { parameters: { query?: never; header?: never; @@ -2236,98 +2335,115 @@ interface paths { get?: never; put?: never; /** - * Draft a message with AI - * @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. + * Clone a workflow + * @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. * - * **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. + * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. * - * That 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. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + post: operations["v1CloneWorkflow"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/{id}/executions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a workflow's executions + * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. * - * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1ListWorkflowExecutions"]; + put?: never; + /** + * Start a workflow for a contact + * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. * - * Drafting is capped at 120 requests per hour per project. + * 409 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. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["draftMailboxMessage"]; + post: operations["v1StartWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/messages": { + "/api/v1/workflows/{id}/graph": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a message from a mailbox - * @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. + * Retrieve a workflow's step graph + * @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. * - * **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. + * A 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. * - * **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. + * The 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. * - * Bcc 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. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1GetWorkflowGraph"]; + /** + * Replace a workflow's step graph + * @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. * - * Refusals worth handling by name: + * A 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. * - * - `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. - * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. - * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. - * - `429` — a mailbox may send 60 messages an hour through this endpoint. + * Refused 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. * - * The message is stored as a new conversation on the mailbox, so the reply threads onto it. + * The 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. * - * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["sendMailboxMessage"]; + put: operations["v1ReplaceWorkflowGraph"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords": { + "/api/v1/workflows/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List a mailbox's app passwords - * @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. - * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. - */ - get: operations["listAppPasswords"]; + get?: never; put?: never; /** - * Create an app password - * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. - * - * **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. + * Pause a workflow and cancel its running executions + * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. * - * That 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. + * `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. * - * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createAppPassword"]; + post: operations["v1PauseWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords/{passwordId}": { + "/api/v1/workflows/{id}/resume": { parameters: { query?: never; header?: never; @@ -2336,39 +2452,39 @@ interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an app password - * @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. + * Resume a paused workflow + * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. * - * Requires an admin of the project. An API key is refused with 401. + * Refused 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeAppPassword"]; + post: operations["v1ResumeWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/users/me/projects": { + "/api/v1/workflows/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create a project - * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * Retrieve workflow statistics + * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. * - * Preconditions 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. + * The 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. * - * Requires the `projects:write` scope — Create new projects on your account. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - post: operations["createProject"]; + get: operations["v1GetWorkflowStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; @@ -2395,7 +2511,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/topics": { + "/api/webhooks": { parameters: { query?: never; header?: never; @@ -2403,87 +2519,27 @@ interface paths { cookie?: never; }; /** - * List topics - * @description The subjects this project mails about, cursor-paginated and newest first. - * - * Archived 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. - * - * `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. - * - * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. - */ - get: operations["v1ListTopics"]; - put?: never; - /** - * Create a topic - * @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. - * - * `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`. - * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. - */ - post: operations["v1CreateTopic"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/topics/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Retrieve a topic - * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. - */ - get: operations["v1GetTopic"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** - * Update a topic - * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. - * - * `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. + * List user webhooks + * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - patch: operations["v1UpdateTopic"]; - trace?: never; - }; - "/api/v1/topics/{id}/subscriptions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; + get: operations["listWebhooks"]; put?: never; /** - * Subscribe or unsubscribe a contact from a topic - * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. - * - * `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. - * - * `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. + * Create a webhook + * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1SetTopicSubscription"]; + post: operations["createWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/contacts/{id}/topics": { + "/api/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -2491,79 +2547,29 @@ interface paths { cookie?: never; }; /** - * Get a contact's topic preferences - * @description Everything this contact has said they want, as the send path reads it. - * - * `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. - * - * The 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. - * - * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + * Get a webhook + * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetContactTopicPreferences"]; + get: operations["getWebhook"]; put?: never; post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/email-validations": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Validate a batch of email addresses - * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. - * - * The 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. - * - * `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. - * - * The 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. + * Delete a webhook + * @description Hard-delete a webhook. Cascades to all WebhookCall rows. * - * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1ValidateEmails"]; - delete?: never; + delete: operations["deleteWebhook"]; options?: never; head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/lists/{id}/validation-runs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Validate every address on a list - * @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. - * - * This 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`. - * - * A 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. - * - * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + * Update a webhook + * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1StartListValidationRun"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["updateWebhook"]; trace?: never; }; - "/api/v1/validation-runs/{id}": { + "/api/webhooks/{id}/calls": { parameters: { query?: never; header?: never; @@ -2571,14 +2577,12 @@ interface paths { cookie?: never; }; /** - * Retrieve a validation run - * @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. - * - * There 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. + * List recent webhook calls + * @description Cursor-paginated list of recent delivery attempts for a single webhook. * - * Requires the `validation:read` scope — View your email validation runs and their results. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetValidationRun"]; + get: operations["listWebhookCalls"]; put?: never; post?: never; delete?: never; @@ -2587,26 +2591,22 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/validation-runs/{id}/results": { + "/api/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List a validation run's results - * @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. - * - * No 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. - * - * `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. + * Rotate the webhook signing secret + * @description Generate a new shared secret. Returns the new plaintext secret exactly once. * - * Requires the `validation:read` scope — View your email validation runs and their results. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1ListValidationRunResults"]; - put?: never; - post?: never; + post: operations["rotateWebhookSecret"]; delete?: never; options?: never; head?: never; @@ -2616,2616 +2616,2633 @@ interface paths { } interface components { schemas: { - /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ - Error: { - /** @enum {boolean} */ - success?: false; - error: { - message: string; - code: string; - details?: { - errors: unknown[]; - }; - }; - }; - /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ - Problem: { + /** @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. */ + AddDomainBody: { + domain: string; + /** Format: uuid */ + projectId?: string; /** - * Format: uri - * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. + * @enum {string} */ - type: string; - /** @description Short, stable summary — the same for every occurrence of a `type`. */ - title: string; - /** @description HTTP status code, repeated in the body. */ - status: number; - /** @description Explanation specific to this occurrence. */ - detail?: string; - /** @description Request path the failure occurred on. */ - instance?: string; - /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ - code: string; - /** @description Correlation id — quote it in support requests. */ - request_id?: string; - /** @description Field-level failures. Present on 422 `validation_error` responses. */ - errors?: { - /** @description RFC 6901 JSON Pointer to the offending field. */ - pointer: string; - code: string; - message: string; - }[]; - }; - /** @description Bare success envelope with no payload. */ - SuccessEmpty: { - /** @enum {boolean} */ - success: true; - }; - /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ - IdResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - id: string; - }; + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"]; + /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ + streamDefault?: boolean; }; - /** @description A subscriber/contact within a project. */ - Contact: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; + /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ + AddSuppression: { /** Format: email */ email: string; - subscribed: boolean; - customFields?: { - [key: string]: unknown; - } | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * @default MANUAL + * @enum {string} */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; - /** @description Cursor-paginated list of contacts. */ - ContactListResponse: { - /** @enum {boolean} */ - success: true; + /** @description Campaign counters and engagement over the window. */ + AnalyticsCampaignStatsV1: { + /** @description Campaigns in DRAFT or SCHEDULED. */ + active: number; + average_click_rate: number; + /** @description Percentage, one decimal place. */ + average_open_rate: number; + completed: number; + total: number; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ + AnalyticsTimeseriesV1: { data: { - data: components["schemas"]["Contact"][]; - total: number; - /** @description Cursor for the next page, or null on the last page. */ - nextCursor: string | null; - hasMore: boolean; - }; + bounces: number; + clicks: number; + /** Format: date-time */ + date: string; + delivered: number; + emails: number; + opens: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; - /** @description A reusable email template. */ - Template: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - description?: string | null; - subject: string; - body: string; - /** Format: email */ + /** @description Sent campaigns ranked by open rate. */ + AnalyticsTopCampaignsV1: { + data: { + click_rate: number; + clicked: number; + /** Format: uuid */ + id: string; + open_rate: number; + opened: number; + sent: number; + subject: string; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description The time range this response was computed over, after the 90-day clamp. */ + AnalyticsWindowV1: { + /** Format: date-time */ from: string; - fromName?: string | null; - /** Format: email */ - replyTo?: string | null; - /** @enum {string} */ - emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; + /** Format: date-time */ + to: string; + }; + /** @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. */ + ApiKey: { /** * Format: date-time * @description ISO 8601 datetime string */ createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description Cursor-paginated list of templates. */ - TemplateListResponse: { - /** @enum {boolean} */ - success: true; - data: { - data: components["schemas"]["Template"][]; - total: number; - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - hasMore: boolean; - }; - }; - /** @description A reusable fragment of template markup. */ - Snippet: { /** Format: uuid */ - id: string; + domainId: string | null; /** Format: uuid */ - projectId: string; - /** @description The literal identifier a template includes with `{{> name}}`. */ - name: string; - description?: string | null; - /** @description Template markup. Values it interpolates are escaped like any other. */ - body: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; + id: string; + /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ + lastFour: string; /** * Format: date-time * @description ISO 8601 datetime string */ - updatedAt: string; - }; - /** @description Cursor-paginated list of snippets. */ - SnippetListResponse: { - /** @enum {boolean} */ - success: true; - data: { - data: components["schemas"]["Snippet"][]; - total: number; - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - hasMore: boolean; - }; - }; - /** - * @description Delivery lifecycle of the message. Engagement is reported separately. - * @enum {string} - */ - EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ - Domain: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - verified: boolean; - region?: string | null; - dkim?: { - type: string; - name: string; - value: string; - }[]; - /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + lastUsedAt: string | null; /** - * @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 {string|null} + * @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 {string} */ - stream?: "TRANSACTIONAL" | "MARKETING" | null; - /** @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). */ - streamDefault?: boolean; - /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ - defaultFromAddress?: string | null; + legacyGrantPreset: "FULL" | "SENDING_ONLY"; /** - * Format: date-time - * @description ISO 8601 datetime string + * @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 {string} */ - createdAt: string; + mode: "LIVE" | "TEST"; + name: string; + /** Format: uuid */ + projectId: string; /** * Format: date-time - * @description ISO 8601 datetime string + * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. */ - updatedAt: string; + revokedAt: string | null; + /** @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. */ + scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; }; - /** @description List of all domains for the auth'd project. */ - DomainListResponse: { + /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ + ApiKeyListResponse: { + data: components["schemas"]["ApiKey"][]; /** @enum {boolean} */ success: true; - data: components["schemas"]["Domain"][]; - }; - /** @description Outcome of a verification check against SES. */ - DomainVerificationStatus: { - verified: boolean; - mxRecords?: string[]; - dkim?: { - type: string; - name: string; - value: string; - }[]; - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; }; - /** @description A sent (or queued) transactional email. */ - Email: { + /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ + AppPassword: { + /** Format: date-time */ + createdAt: string; /** Format: uuid */ id: string; - /** Format: uuid */ - projectId: string; - from: string; - to: string; - subject: string; - status: components["schemas"]["EmailDeliveryStatus"]; + /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ + lastFour: string; /** * Format: date-time - * @description First open, or null. + * @description Null until a mail client has authenticated with it at least once. */ - openedAt: string | null; + lastUsedAt: string | null; + /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ + name: string; + /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ + scopes: ("imap" | "smtp")[]; + }; + /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ + AppPasswordReveal: { + /** Format: uuid */ + id: string; /** * Format: date-time - * @description First click, or null. + * @description When the link stops working. Five minutes after creation; the password itself does not expire. */ - clickedAt: string | null; + revealExpiresAt: string; /** - * Format: date-time - * @description Spam complaint, or null. + * Format: uri + * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. */ - complainedAt: string | null; - /** @description Total opens recorded. */ - opens: number; - /** @description Total clicks recorded. */ - clicks: number; - tags: string[]; - error?: string | null; + revealUrl: string; + }; + /** @description Body for PATCH /api/domains/{id}. */ + AssignDomainStream: { /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: email + * @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. */ - createdAt: string; + defaultFromAddress?: string | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. + * @enum {string|null} */ - updatedAt: string; - }; - /** @description One transition in a message's delivery history. */ - EmailEvent: { - /** Format: uuid */ - id: string; - status: components["schemas"]["EmailDeliveryStatus"]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - /** @description A transactional email together with its delivery history. */ - EmailWithEvents: components["schemas"]["Email"] & { - /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ - events: components["schemas"]["EmailEvent"][]; - }; - /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ - SendEmailRecipientResult: { - contact: { - /** Format: uuid */ - id: string; - /** Format: email */ - email: string; - }; - /** Format: uuid */ - email: string; - }; - /** @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`. */ - SendEmailData: { - emails: components["schemas"]["SendEmailRecipientResult"][]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - /** @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. */ - SendEmailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["SendEmailData"]; + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @description Make this the project's default identity for its stream, demoting whichever held it. */ + streamDefault?: boolean; }; /** @description Per-row result in a batch send response. */ BatchEntryResult: { - index: number; - /** @enum {string} */ - status: "ok" | "error"; data?: components["schemas"]["SendEmailData"]; error?: { - message: string; code: string; + message: string; }; - }; - /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ - BatchSendResponse: { - success: boolean; - data: components["schemas"]["BatchEntryResult"][]; + index: number; + /** @enum {string} */ + status: "ok" | "error"; }; /** @description Batch send wrapper. Up to 100 entries. */ BatchSendBody: { emails: components["schemas"]["SendEmail"][]; }; - /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ - SendEmail: { - to: string | { - name?: string; - /** Format: email */ - email: string; - } | (string | { - name?: string; - /** Format: email */ - email: string; - })[]; - subject?: string; - body?: string; - /** Format: uuid */ - template?: string; - subscribed?: boolean; - name?: string; - from?: string | { - name?: string; - /** Format: email */ - email: string; - }; - /** Format: email */ - reply?: string; - headers?: { - [key: string]: string; - }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; - }; - attachments?: { - filename: string; - content: string; - contentType: string; - contentId?: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - }[]; - tags?: string[]; - cc?: string[]; - bcc?: string[]; - }; - /** @description Bulk create up to 1000 contacts. */ - ContactBulkCreateBody: { - contacts: components["schemas"]["CreateContact"][]; - }; - /** @description Body for POST /api/contacts and /api/contacts/upsert. */ - CreateContact: { - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - customFields?: { - [key: string]: unknown; - }; - }; - /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ - ContactBulkDeleteBody: { - ids?: string[]; - emails?: string[]; - }; - /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ - UpdateContactBody: { - subscribed?: boolean; - customFields?: { - [key: string]: unknown; - }; + /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ + BatchSendResponse: { + data: components["schemas"]["BatchEntryResult"][]; + success: boolean; }; - /** @description A single suppressed-email record. */ - Suppression: { + /** @description A campaign as exposed on the v1 API. */ + CampaignV1: { + /** @enum {string} */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + /** Format: date-time */ + created_at: string; /** Format: uuid */ id: string; /** Format: uuid */ - projectId: string; - /** Format: email */ - email: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + list_id: string | null; + name: string; + /** Format: date-time */ + scheduled_at: string | null; + /** Format: date-time */ + sent_at: string | null; + stats: { + bounced: number; + clicked: number; + delivered: number; + opened: number; + sent: number; + total_recipients: number; + }; /** @enum {string} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD"; + status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; + subject: string; + /** Format: uuid */ + topic_id: string | null; + }; + /** @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`. */ + CampaignV1Create: { + audience_condition?: components["schemas"]["FilterConditionV1"]; /** - * Format: date-time - * @description ISO 8601 datetime string + * @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 {string} */ - createdAt: string; - }; - /** @description Cursor-paginated list of suppressions. */ - SuppressionListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Suppression"][]; - nextCursor?: string | null; - cursor?: string | null; - hasMore?: boolean; - }; - /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ - SuppressionCheckResponse: { - suppressed: boolean; - /** @enum {string} */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + body: string; + description?: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * @default MARKETING + * @enum {string} */ - createdAt?: string; - }; - /** @description A user-managed outbound webhook. */ - Webhook: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** Format: uri */ - url: string; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - consecutiveFailures: number; + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: email + * @description Sender address. Its domain must be verified for this project. */ - disabledAt?: string | null; - lastFour?: string; + from: string; + from_name?: string | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. */ - createdAt: string; + list_id?: string; + name: string; + /** Format: email */ + reply_to?: string | null; + /** Format: uuid */ + segment_id?: string; + subject: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @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. */ - updatedAt: string; + topic_id?: string | null; }; - /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ - WebhookCreateResponse: { + /** @description Acknowledgement that a campaign was deleted. */ + CampaignV1Deleted: { /** @enum {boolean} */ - success: true; - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { - /** @description Plaintext shared secret. Returned ONCE on create. */ - secret: string; - }; + deleted: true; + /** Format: uuid */ + id: string; }; - /** @description List of webhooks for the auth'd project. */ - WebhookListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Webhook"][]; + /** @description A campaign recipient whose send did not complete. */ + CampaignV1Failure: { + /** Format: uuid */ + contact_id: string; + /** @description The recipient the send was for. */ + email: string; + /** Format: date-time */ + failed_at: string; + /** + * Format: uuid + * @description Ledger row id. Pass the last one as `after` to page. + */ + id: string; + reason: string | null; }; - /** @description Single webhook (no secret). */ - WebhookGetResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Webhook"]; + /** @description Cursor-paginated list of a campaign's failed sends. */ + CampaignV1FailureList: { + data: components["schemas"]["CampaignV1Failure"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + /** @description Every FAILED row on this campaign, not just this page. */ + total: number; }; - /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ - WebhookRotateSecretResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - id: string; - /** @description New plaintext shared secret. */ - secret: string; - }; + /** @description Cursor-paginated list of campaigns. */ + CampaignV1List: { + data: components["schemas"]["CampaignV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description An attempted webhook delivery. */ - WebhookCall: { + /** @description Acknowledgement that a retry of a campaign's failed sends began. */ + CampaignV1RetryFailed: { /** Format: uuid */ id: string; - /** Format: uuid */ - webhookId: string; - eventType: string; - payload: { - [key: string]: unknown; - }; - /** @enum {string} */ - status: "PENDING" | "SUCCESS" | "FAILED"; - attempt: number; - responseStatus?: number | null; - responseBody?: string | null; + /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ + queued: number; + }; + /** @description Body for POST /api/v1/campaigns/{id}/send. */ + CampaignV1Send: { /** * Format: date-time - * @description ISO 8601 datetime string + * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. */ - createdAt: string; - }; - /** @description Cursor-paginated list of recent calls for a single webhook. */ - WebhookCallsListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["WebhookCall"][]; - nextCursor?: string | null; - cursor?: string | null; - hasMore?: boolean; - }; - /** @description Response from POST /api/track. */ - TrackEventResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - contact: string; - /** Format: uuid */ - event: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - }; - /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ - VerifyEmailResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: email */ - email: string; - valid: boolean; - reason?: string; - } & { - [key: string]: unknown; - }; - }; - /** @description Cursor-paginated list of emails. */ - EmailListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Email"][]; - nextCursor?: string | null; - }; - /** @description A single email. */ - EmailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Email"]; - }; - /** @description One email and its delivery history. */ - EmailDetailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["EmailWithEvents"]; - }; - /** @description Result of a list-subscribe call. */ - ListSubscribeResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - membershipId: string; - /** @enum {string} */ - status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; - /** @description True when the membership row did not exist before this call. */ - created: boolean; - /** - * @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 {string|null} - */ - previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; - /** @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. */ - confirmToken?: string; - }; + scheduled_for?: string; }; - /** @description Echoes the address that was unsubscribed. */ - ListUnsubscribeResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: email */ - email: string; - }; + /** @description Materialized delivery and engagement counters for one campaign. */ + CampaignV1Stats: { + bounce_rate: number; + bounced: number; + click_rate: number; + clicked: number; + delivered: number; + delivery_rate: number; + open_rate: number; + opened: number; + sent: number; + total_recipients: number; }; - /** @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. */ - ApiKey: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ - lastFour: string; + /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ + CampaignV1Update: { + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** @enum {string} */ + audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + body?: string; + description?: string; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** - * @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 {string} + * Format: email + * @description Sender address. Its domain must be verified for this project. */ - legacyGrantPreset: "FULL" | "SENDING_ONLY"; + from?: string; + from_name?: string | null; /** - * @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 {string} + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. */ - mode: "LIVE" | "TEST"; - /** @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. */ - scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + list_id?: string; + name?: string; + /** Format: email */ + reply_to?: string | null; /** Format: uuid */ - domainId: string | null; + segment_id?: string; + subject?: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @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. */ - lastUsedAt: string | null; + topic_id?: string | null; + }; + /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ + ComposeMailboxMessage: { + bcc?: string[]; + body: string; + cc?: string[]; + subject: string; + to: string[]; + }; + /** @description A subscriber/contact within a project. */ + Contact: { /** * Format: date-time * @description ISO 8601 datetime string */ createdAt: string; + customFields?: { + [key: string]: unknown; + } | null; + /** Format: email */ + email: string; + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + subscribed: boolean; /** * Format: date-time - * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. + * @description ISO 8601 datetime string */ - revokedAt: string | null; + updatedAt: string; }; - /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ - ApiKeyListResponse: { + /** @description Bulk create up to 1000 contacts. */ + ContactBulkCreateBody: { + contacts: components["schemas"]["CreateContact"][]; + }; + /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ + ContactBulkDeleteBody: { + emails?: string[]; + ids?: string[]; + }; + /** @description Cursor-paginated list of contacts. */ + ContactListResponse: { + data: { + data: components["schemas"]["Contact"][]; + hasMore: boolean; + /** @description Cursor for the next page, or null on the last page. */ + nextCursor: string | null; + total: number; + }; /** @enum {boolean} */ success: true; - data: components["schemas"]["ApiKey"][]; }; - ProjectRecord: { + /** @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. */ + ContactTopicPreferencesV1: { + contact_id: string; + /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ + subscribed: boolean; + topics: { + /** @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. */ + key: string; + name: string; + pending: boolean; + /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ + subscribed: boolean; + topic_id: string; + }[]; + }; + /** @description A contact as exposed on the v1 API. */ + ContactV1: { + /** Format: date-time */ + created_at: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + email: string; /** Format: uuid */ id: string; - name: string; - disabled: boolean; - disabledReason: string | null; - /** @description Local-part of the sandbox quick-start sender; null until first derived. */ - sandboxHandle: string | null; - stripeCustomerId: string | null; - stripeSubscriptionId: string | null; - billingLimitWorkflows: number | null; - billingLimitCampaigns: number | null; - billingLimitTransactional: number | null; - billingLimitInbound: number | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - sesRegion: string | null; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - organizationId: string | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + subscribed: boolean; + /** Format: date-time */ + updated_at: string; }; - /** @description Cursor-paginated list of campaigns. */ - CampaignV1List: { - data: components["schemas"]["CampaignV1"][]; + /** @description Body for POST /api/v1/contacts. */ + ContactV1Create: { + /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + }; + /** @description Acknowledgement that a contact was deleted. */ + ContactV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactV1List: { + data: components["schemas"]["ContactV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A campaign as exposed on the v1 API. */ - CampaignV1: { + /** @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. */ + ContactV1Update: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + subscribed?: boolean; + }; + CreateApiKeyBody: { /** Format: uuid */ - id: string; - name: string; - /** @enum {string} */ - status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; - subject: string; + domainId?: string | null; /** @enum {string} */ - audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - /** Format: uuid */ - list_id: string | null; - /** Format: uuid */ - topic_id: string | null; - /** Format: date-time */ - scheduled_at: string | null; - /** Format: date-time */ - sent_at: string | null; - /** Format: date-time */ - created_at: string; - stats: { - total_recipients: number; - sent: number; - delivered: number; - opened: number; - clicked: number; - bounced: number; - }; - }; - /** @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`. */ - CampaignV1Create: { - name: string; - description?: string; - subject: string; - body: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from: string; - from_name?: string | null; - /** Format: email */ - reply_to?: string | null; + legacyGrantPreset?: "FULL" | "SENDING_ONLY"; /** - * @default MARKETING + * @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 {string} */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + mode?: "LIVE" | "TEST"; + name: string; + /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ + scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + }; + /** @description Body for POST /api/mailboxes/:id/app-passwords. */ + CreateAppPassword: { + name: string; /** - * @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 {string} + * @default [ + * "imap", + * "smtp" + * ] */ - audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** Format: uuid */ - segment_id?: string; + scopes: ("imap" | "smtp")[]; + }; + /** @description Body for POST /api/contacts and /api/contacts/upsert. */ + CreateContact: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + customFields?: { + [key: string]: unknown; + }; + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + }; + CreateMailboxBody: { + displayName?: string; /** * Format: uuid - * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + * @description A VERIFIED domain belonging to this project. */ - list_id?: string; + domainId: string; + /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ + localPart: string; /** * Format: uuid - * @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. + * @description Defaults to the project the credential resolves to. Naming a different one is refused. */ - topic_id?: string | null; - }; - /** @description A filter condition: one or more groups combined with `logic`. */ - FilterConditionV1: { - /** @enum {string} */ - logic: "AND" | "OR"; - groups: components["schemas"]["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. */ - FilterGroupV1: { - filters: components["schemas"]["SegmentFilterV1"][]; - conditions?: components["schemas"]["FilterConditionV1"]; + projectId?: string; + /** @description NOT IMPLEMENTED — sending any value answers 400. */ + quotaBytes?: number; }; - /** @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). */ - SegmentFilterV1: { - field: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; - value?: unknown; - /** @enum {string} */ - unit?: "days" | "hours" | "minutes"; + /** @description Body for POST /api/snippets. */ + CreateSnippet: { + body: string; + description?: string | null; + name: string; }; - /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ - CampaignV1Update: { - name?: string; + /** @description Body for POST /api/templates. */ + CreateTemplate: { + body: string; description?: string; - subject?: string; - body?: string; /** - * Format: email - * @description Sender address. Its domain must be verified for this project. + * @default MARKETING + * @enum {string} */ - from?: string; - from_name?: string | null; + emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ - reply_to?: string | null; - /** @enum {string} */ - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - /** @enum {string} */ - audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** Format: uuid */ - segment_id?: string; - /** - * Format: uuid - * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. - */ - list_id?: string; - /** - * Format: uuid - * @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. - */ - topic_id?: string | null; + from: string; + fromName?: string | null; + name: string; + /** Format: email */ + replyTo?: string | null; + subject: string; }; - /** @description Acknowledgement that a campaign was deleted. */ - CampaignV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; + /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ + CreateWebhook: { + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** Format: uri */ + url: string; }; - /** @description Body for POST /api/v1/campaigns/{id}/send. */ - CampaignV1Send: { - /** + /** @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. */ + DeliverabilityDiagnosisV1: { + address: string | null; + /** Format: date-time */ + checked_at: string; + domain: string; + /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ + findings: components["schemas"]["DeliverabilityFindingV1"][]; + identity: components["schemas"]["DeliverabilityIdentityV1"]; + recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; + suppression: components["schemas"]["DeliverabilitySuppressionV1"]; + }; + /** + * @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 {string} + */ + DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; + /** @description One diagnosed problem, with its fix. */ + DeliverabilityFindingV1: { + /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ + code: string; + /** @description What to do about it. */ + remedy: string; + severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; + /** @description What is wrong, in one sentence. */ + summary: string; + }; + /** @description The sending identity's DNS health, as last refreshed. */ + DeliverabilityIdentityV1: { + /** + * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. + * @enum {string|null} + */ + dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description The DMARC policy published at `_dmarc.`. + * @enum {string|null} + */ + dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** * Format: date-time - * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. + * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. */ - scheduled_for?: string; + last_checked_at: string | null; + mail_from_domain: string | null; + /** @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. */ + mail_from_domain_status: string | null; + /** + * @description Inbound receiving only. Null unless the domain has receiving enabled. + * @enum {string|null} + */ + mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description Whether this project has a domain record at all. False makes every other field null. */ + registered: boolean; + /** + * @description SPF alignment for the sending identity. + * @enum {string|null} + */ + spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + verified: boolean; }; - /** @description Materialized delivery and engagement counters for one campaign. */ - CampaignV1Stats: { - total_recipients: number; - sent: number; - delivered: number; - opened: number; - clicked: number; + /** @description Delivery outcomes over the requested window. */ + DeliverabilityRecentDeliveryV1: { + /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ + bounce_rate: number | null; bounced: number; - open_rate: number; - click_rate: number; - bounce_rate: number; - delivery_rate: number; - }; - /** @description Cursor-paginated list of a campaign's failed sends. */ - CampaignV1FailureList: { - data: components["schemas"]["CampaignV1Failure"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - /** @description Every FAILED row on this campaign, not just this page. */ - total: number; - }; - /** @description A campaign recipient whose send did not complete. */ - CampaignV1Failure: { + complained: number; + complaint_rate: number | null; + delivered: number; + failed: number; /** - * Format: uuid - * @description Ledger row id. Pass the last one as `after` to page. + * @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 {string} */ - id: string; - /** Format: uuid */ - contact_id: string; - /** @description The recipient the send was for. */ - email: string; - reason: string | null; - /** Format: date-time */ - failed_at: string; + scope: "project"; + sent: number; + window_days: number; }; - /** @description Acknowledgement that a retry of a campaign's failed sends began. */ - CampaignV1RetryFailed: { - /** Format: uuid */ + /** @description Null unless the request named an `address`. */ + DeliverabilitySuppressionV1: { + /** @enum {string|null} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; + /** @enum {string|null} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; + suppressed: boolean; + /** Format: date-time */ + suppressed_at: string | null; + } | null; + /** @description One DMARC aggregate (RUA) report. */ + DmarcReportV1: { + fail_count: number; id: string; - /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ - queued: number; + /** @description The reporting receiver, e.g. `google.com`. */ + org_name: string; + /** @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. */ + pass_count: number; + /** @description The domain of yours the report is about. */ + policy_domain: string; + /** Format: date-time */ + range_begin: string; + /** Format: date-time */ + range_end: string; + /** Format: date-time */ + received_at: string; + /** @description The receiver's own id for this report. */ + report_id: string; + /** @description Per-sending-source rows, as the receiver reported them. */ + sources: { + count: number; + disposition: string; + dkim: string; + header_from: string; + source_ip: string; + spf: string; + }[]; + total_count: number; }; - /** @description Cursor-paginated list of segments. */ - SegmentV1List: { - data: components["schemas"]["SegmentV1"][]; + /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ + DmarcReportV1List: { + data: components["schemas"]["DmarcReportV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A segment as exposed on the v1 API. */ - SegmentV1: { + /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ + Domain: { + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ + defaultFromAddress?: string | null; + dkim?: { + name: string; + type: string; + value: string; + }[]; /** Format: uuid */ id: string; + /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; name: string; - description: string | null; - /** @enum {string} */ - type: "DYNAMIC" | "STATIC"; - condition: components["schemas"]["FilterConditionV1"] | null; - track_membership: boolean; - member_count: number; + /** Format: uuid */ + projectId: string; + region?: string | null; + /** + * @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 {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @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). */ + streamDefault?: boolean; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + verified: boolean; + }; + /** @description List of all domains for the auth'd project. */ + DomainListResponse: { + data: components["schemas"]["Domain"][]; + /** @enum {boolean} */ + success: true; + }; + /** @description A sending domain as exposed on the v1 API. */ + DomainV1: { /** Format: date-time */ created_at: string; + default_from_address: string | null; + dkim_verified: boolean; + domain: string; + /** Format: uuid */ + id: string; + mail_from_domain: string | null; + /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ + mail_from_domain_status: string | null; + region: string | null; + stream: components["schemas"]["SendingStream"] & (string | null); + stream_default: boolean; /** Format: date-time */ updated_at: string; + verified: boolean; }; - /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ - SegmentV1Create: { - name: string; - description?: string; + /** @description Body for POST /api/v1/domains. */ + DomainV1Create: { + domain: string; /** - * @default DYNAMIC + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. * @enum {string} */ - type: "DYNAMIC" | "STATIC"; - condition?: components["schemas"]["FilterConditionV1"]; - /** - * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. - * @default false - */ - track_membership: boolean; - }; - /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ - SegmentV1Update: { - name?: string; - description?: string; - condition?: components["schemas"]["FilterConditionV1"]; - track_membership?: boolean; + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"] & unknown; + /** @description Make this the project's default identity for `stream`. Requires `stream`. */ + stream_default?: boolean; }; - /** @description Acknowledgement that a segment was deleted. */ - SegmentV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Acknowledgement that a sending domain was removed. */ + DomainV1Deleted: { /** @enum {boolean} */ deleted: true; + /** Format: uuid */ + id: string; }; - /** @description Cursor-paginated list of the contacts belonging to a segment. */ - SegmentContactV1List: { - data: components["schemas"]["SegmentContactV1"][]; + /** @description Cursor-paginated list of sending domains. */ + DomainV1List: { + data: components["schemas"]["DomainV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A contact belonging to a segment. */ - SegmentContactV1: { - /** Format: uuid */ - id: string; - email: string; - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: { - [key: string]: unknown; - }; - /** Format: date-time */ - created_at: string; + /** @description Outcome of a verification check against SES. */ + DomainVerificationStatus: { + dkim?: { + name: string; + type: string; + value: string; + }[]; + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + mxRecords?: string[]; + verified: boolean; }; - /** @description Cursor-paginated list of workflows. */ - WorkflowV1List: { - data: components["schemas"]["WorkflowV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ + DraftMailboxMessage: { + brief?: string; + draft?: string; + instruction?: string; + /** @enum {string} */ + mode: "draft" | "rewrite" | "subject"; + recipientContext?: string; + senderAddress?: string; + /** @enum {string} */ + tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; }; - /** @description An automation workflow as exposed on the v1 API. */ - WorkflowV1: { + /** @description A sent (or queued) transactional email. */ + Email: { + /** + * Format: date-time + * @description Bounced, or null. + */ + bouncedAt: string | null; + /** + * Format: date-time + * @description First click, or null. + */ + clickedAt: string | null; + /** @description Total clicks recorded. */ + clicks: number; + /** + * Format: date-time + * @description Spam complaint, or null. + */ + complainedAt: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description Accepted by the recipient's server, or null. + */ + deliveredAt: string | null; + error?: string | null; + from: string; /** Format: uuid */ id: string; - name: string; - description: string | null; - enabled: boolean; - /** @enum {string} */ - trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; - /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ - event_name: string | null; - allow_reentry: boolean; - max_executions_per_hour: number | null; - /** @description Incremented on every structural (step/transition) change. */ - version: number; - /** @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. */ - step_count: number; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description Body for POST /api/v1/workflows. */ - WorkflowCreateV1: { - name: string; - description?: string; - trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; - /** @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. */ - event_name?: string; - /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ - interval_ms?: number; - /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ - enabled?: boolean; - allow_reentry?: boolean; - /** @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. */ - sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; - }; - /** - * @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 {string} - */ - WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; - /** @description One step of a linear workflow sequence. */ - WorkflowSequenceStepV1: { - type: components["schemas"]["WorkflowSequenceStepTypeV1"]; - /** @description Human-readable label, e.g. `Day 0: welcome`. */ - name: string; - /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ - config: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; /** - * Format: uuid - * @description For `SEND_EMAIL`: a template in this project. + * Format: date-time + * @description First open, or null. */ - template_id?: string; + openedAt: string | null; + /** @description Total opens recorded. */ + opens: number; + /** Format: uuid */ + projectId: string; + /** + * Format: date-time + * @description Handed to the provider, or null. + */ + sentAt: string | null; + status: components["schemas"]["EmailDeliveryStatus"]; + subject: string; + tags: string[]; + to: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; }; /** - * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @description Delivery lifecycle of the message. Engagement is reported separately. * @enum {string} */ - WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; - /** @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. */ - WorkflowUpdateV1: { - name?: string; - description?: string; - trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; - event_name?: string; - /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ - interval_ms?: number; - enabled?: boolean; - allow_reentry?: boolean; - /** @description Per-workflow start rate cap. `null` removes the cap. */ - max_executions_per_hour?: number | null; - /** @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. */ - sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** @description One email and its delivery history. */ + EmailDetailResponse: { + data: components["schemas"]["EmailWithEvents"]; + /** @enum {boolean} */ + success: true; }; - /** @description Confirmation that a workflow was deleted. */ - WorkflowDeletedV1: { + /** @description One transition in a message's delivery history. */ + EmailEvent: { /** Format: uuid */ id: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description Cursor-paginated list of emails. */ + EmailListResponse: { + data: components["schemas"]["Email"][]; + nextCursor?: string | null; /** @enum {boolean} */ - deleted: true; + success: true; }; - /** @description Cursor-paginated list of workflow executions, newest first. */ - WorkflowExecutionV1List: { - data: components["schemas"]["WorkflowExecutionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description A single email. */ + EmailResponse: { + data: components["schemas"]["Email"]; + /** @enum {boolean} */ + success: true; }; - /** @description One contact's run through a workflow. */ - WorkflowExecutionV1: { - /** Format: uuid */ + /** @description Receipt for a sandbox test send. */ + EmailTestV1: { + /** + * Format: email + * @description This project's sandbox sender — resolved server-side, never from the body. + */ + from: string; + /** + * Format: uuid + * @description The Email row this send created. + */ id: string; - /** Format: uuid */ - workflow_id: string; - /** Format: uuid */ - contact_id: string; - /** @enum {string} */ - status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - /** Format: uuid */ - current_step_id: string | null; - exit_reason: string | null; - /** Format: date-time */ - started_at: string; - /** Format: date-time */ - completed_at: string | null; + /** + * @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 {boolean} + */ + sandbox: true; + /** + * @description Delivery status at the moment of the response — `PENDING` for a send still queued. + * @enum {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; }; - /** @description Body for POST /api/v1/workflows/{id}/executions. */ - WorkflowExecutionStartV1: { + /** @description Receipt for a single transactional send. */ + EmailV1: { + /** + * Format: email + * @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. + */ + from: string; /** * Format: uuid - * @description Contact to enter the workflow. Must belong to this project. + * @description The Email row this send created. Quote it in support requests. */ - contact_id: string; - /** @description Extra variables merged into the contact's data for this run. */ - context?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + id: string; + /** + * @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 {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; }; - /** @description Execution, email and conversion totals for one workflow. */ - WorkflowStatsV1: { - /** Format: uuid */ - workflow_id: string; - name: string; - enabled: boolean; - trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; - /** @description Steps in the workflow's graph, trigger step included. */ - step_count: number; - total: number; - /** @description Execution counts keyed by status; a status with no executions is absent. */ - by_status: { - [key: string]: number; - }; - /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ - completion_rate: number | null; - avg_duration_ms: number | null; - emails: { - sent: number; - opened: number; - clicked: number; + EmailValidationBatchRequestV1: { + /** @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. */ + emails: string[]; + }; + /** @description One verdict per address, in the order they were given. */ + EmailValidationBatchV1: { + results: components["schemas"]["EmailValidationV1"][]; + }; + /** @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. */ + EmailValidationResultListV1: { + data: (components["schemas"]["EmailValidationV1"] & { + contact_id: string | null; + })[]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One bulk validation run over a list. */ + EmailValidationRunV1: { + /** Format: date-time */ + completed_at: string | null; + /** Format: date-time */ + created_at: string; + deliverable_count: number; + /** @description Set only on `failed`. Prose for an operator; never parse it. */ + failure_reason: string | null; + id: string; + list_id: string | null; + /** @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. */ + processed_count: number; + risky_count: number; + /** Format: date-time */ + started_at: string | null; + /** @enum {string} */ + status: "pending" | "running" | "completed" | "failed"; + undeliverable_count: number; + }; + /** @description One address's verdict, with the evidence behind it. */ + EmailValidationV1: { + email: string; + /** @description The domain publishes MX records. */ + has_mx_records: boolean; + /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ + is_disposable: boolean; + /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ + is_personal: boolean; + /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ + is_role_address: boolean; + /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ + reasons: string[]; + verdict: components["schemas"]["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 {string} + */ + EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; + /** @description A transactional email together with its delivery history. */ + EmailWithEvents: components["schemas"]["Email"] & { + /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ + events: components["schemas"]["EmailEvent"][]; + }; + /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ + Error: { + error: { + code: string; + details?: { + errors: unknown[]; + }; + message: string; }; - conversions: { - /** Format: uuid */ - goal_id: string; - name: string; - event_name: string; + /** @enum {boolean} */ + success?: false; + }; + /** @description Every distinct event name in the project, most frequent first. */ + EventNamesV1: { + data: string[]; + }; + /** @description Per-name event counts over the applied window. */ + EventStatsV1: { + data: { count: number; + name: string; }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; - /** @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. */ - WorkflowGraphV1: { - /** Format: uuid */ - workflow_id: string; - /** @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. */ - version: number; - steps: components["schemas"]["WorkflowStepReadV1"][]; - transitions: components["schemas"]["WorkflowTransitionV1"][]; - }; - /** @description One node of a workflow graph, as read. */ - WorkflowStepReadV1: { + /** @description Body for POST /api/v1/events. */ + EventTrackV1: { /** * Format: uuid - * @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. + * @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. */ - id: string; + contact_id?: string; + /** @description Event name, e.g. `user.signup`. */ name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** @enum {string} */ - type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; - /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ - config: { + /** @description Arbitrary event payload. */ + payload?: { [key: string]: string | number | boolean | { [key: string]: unknown; } | unknown[] | null; }; }; - /** @description Where this step sits on the editor canvas. */ - WorkflowStepPositionV1: { - x: number; - y: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - /** @description One directed edge between two steps. */ - WorkflowTransitionV1: { - /** - * Format: uuid - * @description Caller-chosen on a write, exactly like a step id. - */ - id: string; + /** @description A recorded custom event. */ + EventV1: { /** Format: uuid */ - from_step_id: string; + contact_id: string | null; + /** Format: date-time */ + created_at: string; /** Format: uuid */ - to_step_id: string; - /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ - condition: string | number | boolean | { + email_id: string | null; + /** Format: uuid */ + id: string; + name: string; + /** @description The payload recorded with the event, or null. */ + payload: { [key: string]: unknown; - } | unknown[] | null; - /** @description Evaluation order among the edges leaving one step; lowest first. */ - priority: number; + } | null; }; - /** @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. */ - WorkflowGraphReplaceV1: { - /** @description The complete step set. Exactly one must be a `TRIGGER`. */ - steps: components["schemas"]["WorkflowStepV1"][]; - /** @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. */ - transitions: components["schemas"]["WorkflowTransitionV1"][]; + /** @description Cursor-paginated list of events, newest first. */ + EventV1List: { + data: components["schemas"]["EventV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description One node of a workflow graph. */ - WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; - /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ - WorkflowTriggerStepV1: { - /** - * Format: uuid - * @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. - */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "TRIGGER"; - config: { - eventName?: string; - intervalMs?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description A filter condition: one or more groups combined with `logic`. */ + FilterConditionV1: { + groups: components["schemas"]["FilterGroupV1"][]; + /** @enum {string} */ + logic: "AND" | "OR"; + }; + /** @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. */ + FilterGroupV1: { + conditions?: components["schemas"]["FilterConditionV1"]; + filters: components["schemas"]["SegmentFilterV1"][]; + }; + /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ + IdResponse: { + data: { + /** Format: uuid */ + id: string; }; + /** @enum {boolean} */ + success: true; }; - /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ - WorkflowSendEmailStepV1: { + /** @description Body for POST /api/lists/{id}/subscribe. */ + ListSubscribe: { /** - * Format: uuid - * @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. + * @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. + * @default false */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "SEND_EMAIL"; - config: { + allowResubscribe: boolean; + /** @description Custom fields to upsert onto the contact as part of subscribing. */ + data?: { + [key: string]: unknown; + }; + /** Format: email */ + email: string; + }; + /** @description Result of a list-subscribe call. */ + ListSubscribeResponse: { + data: { + /** @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. */ + confirmToken?: string; + /** @description True when the membership row did not exist before this call. */ + created: boolean; /** Format: uuid */ - templateId?: string; - subject?: string; - body?: string; - recipient?: { - /** @enum {string} */ - type: "CONTACT" | "CUSTOM"; - /** Format: email */ - customEmail?: string; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + membershipId: string; + /** + * @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 {string|null} + */ + previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; + /** @enum {string} */ + status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; }; + /** @enum {boolean} */ + success: true; }; - /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ - WorkflowDelayStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Body for POST /api/lists/{id}/unsubscribe. */ + ListUnsubscribe: { + /** Format: email */ + email: string; + }; + /** @description Echoes the address that was unsubscribed. */ + ListUnsubscribeResponse: { + data: { + /** Format: email */ + email: string; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description A subscriber list as exposed on the v1 API. */ + ListV1: { + /** Format: uuid */ + confirmation_template_id: string | null; + /** Format: date-time */ + created_at: string; + description: string | null; + double_opt_in: boolean; + /** Format: uuid */ id: string; + /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ + member_count: number; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + redirect_url: string | null; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/lists. */ + ListV1Create: { + /** Format: uuid */ + confirmation_template_id?: string | null; + description?: string | null; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @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. + * @default false */ - template_id?: string | null; + double_opt_in: boolean; + name: string; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Format: uri + * @description Where a confirmed contact is sent after following the confirmation link. */ - type: "DELAY"; - config: { - amount?: number; - /** @enum {string} */ - unit?: "minutes" | "hours" | "days"; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + redirect_url?: string | null; }; - /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ - WorkflowWaitForEventStepV1: { + /** @description Acknowledgement that a list was deleted. */ + ListV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of subscriber lists. */ + ListV1List: { + data: components["schemas"]["ListV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Body for PATCH /api/v1/lists/{id}. */ + ListV1Update: { + /** Format: uuid */ + confirmation_template_id?: string | null; + description?: string | null; + double_opt_in?: boolean; + name?: string; + /** Format: uri */ + redirect_url?: string | null; + }; + /** @description A receiving mailbox on one of the project's verified domains. */ + Mailbox: { /** - * Format: uuid - * @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: email + * @description The full mailbox address, e.g. `support@superbooks.io`. */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + address: string; + /** Format: date-time */ + createdAt: string; + displayName: string | null; /** * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @description The verified domain this mailbox lives on. */ - template_id?: string | null; + domainId: string; + /** Format: uuid */ + id: string; + /** @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. */ + quotaBytes: number | null; /** - * @description discriminator enum property added by openapi-typescript + * @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 {string} */ - type: "WAIT_FOR_EVENT"; - config: { - eventName?: string; - timeout?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; + }; + /** @description A mailbox plus its IMAP/SMTP connection settings. */ + MailboxDetail: components["schemas"]["Mailbox"] & { + /** @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. */ + settings: { + imap: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + smtp: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; }; }; - /** @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. */ - WorkflowConditionStepV1: { + /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ + Problem: { + /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ + code: string; + /** @description Explanation specific to this occurrence. */ + detail?: string; + /** @description Field-level failures. Present on 422 `validation_error` responses. */ + errors?: { + code: string; + message: string; + /** @description RFC 6901 JSON Pointer to the offending field. */ + pointer: string; + }[]; + /** @description Request path the failure occurred on. */ + instance?: string; + /** @description Correlation id — quote it in support requests. */ + request_id?: string; + /** @description HTTP status code, repeated in the body. */ + status: number; + /** @description Short, stable summary — the same for every occurrence of a `type`. */ + title: string; /** - * Format: uuid - * @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: uri + * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + */ + type: string; + }; + ProjectRecord: { + billingLimitCampaigns: number | null; + billingLimitInbound: number | null; + billingLimitTransactional: number | null; + billingLimitWorkflows: number | null; + /** + * Format: date-time + * @description ISO 8601 datetime string */ + createdAt: string; + disabled: boolean; + disabledReason: string | null; + /** Format: uuid */ id: string; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + organizationId: string | null; + /** @description Local-part of the sandbox quick-start sender; null until first derived. */ + sandboxHandle: string | null; + sesRegion: string | null; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * Format: date-time + * @description ISO 8601 datetime string */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "CONDITION"; - config: { - /** @enum {string} */ - mode?: "multi"; - field?: string; - /** @enum {string} */ - operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - value?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - branches?: ({ - id: string; - name: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - value?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - })[]; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + updatedAt: string; }; - /** @description Ends the run early and stamps `exit_reason`. */ - WorkflowExitStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description The project the presented credential is scoped to. */ + ProjectV1: { + /** Format: date-time */ + created_at: string; + /** @description A disabled project sends nothing; every send is refused. */ + disabled: boolean; + /** Format: uuid */ id: string; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; + /** @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. */ + sandbox_address: string | null; + /** @description Locked once the first domain is added. */ + ses_region: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + }; + /** @description Delivery outcomes for one recipient domain on one day. */ + RecipientDomainStatsV1: { + bounced: number; + complained: number; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Format: date-time + * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. */ - type: "EXIT"; - config: { - reason?: string; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + computed_at: string; + /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ + day: string; + delivered: number; + /** @description The recipient's domain, lowercased: the part after the `@`. */ + domain: string; + opened: number; + sent: number; + }; + /** @description Cursor-paginated recipient-domain rollup, newest day first. */ + RecipientDomainStatsV1List: { + data: components["schemas"]["RecipientDomainStatsV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact belonging to a segment. */ + SegmentContactV1: { + /** Format: date-time */ + created_at: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: { + [key: string]: unknown; }; + email: string; + /** Format: uuid */ + id: string; + subscribed: boolean; }; - /** @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. */ - WorkflowWebhookStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Cursor-paginated list of the contacts belonging to a segment. */ + SegmentContactV1List: { + data: components["schemas"]["SegmentContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @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). */ + SegmentFilterV1: { + field: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; + /** @enum {string} */ + unit?: "days" | "hours" | "minutes"; + value?: unknown; + }; + /** @description A segment as exposed on the v1 API. */ + SegmentV1: { + condition: components["schemas"]["FilterConditionV1"] | null; + /** Format: date-time */ + created_at: string; + description: string | null; + /** Format: uuid */ id: string; + member_count: number; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "WEBHOOK"; - config: { - /** Format: uri */ - url?: string; - /** @enum {string} */ - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - headers?: { - [key: string]: string; - }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - body?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + track_membership: boolean; + /** @enum {string} */ + type: "DYNAMIC" | "STATIC"; + /** Format: date-time */ + updated_at: string; }; - /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ - WorkflowUpdateContactStepV1: { - /** - * Format: uuid - * @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. - */ - id: string; + /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ + SegmentV1Create: { + condition?: components["schemas"]["FilterConditionV1"]; + description?: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. + * @default false */ - template_id?: string | null; + track_membership: boolean; /** - * @description discriminator enum property added by openapi-typescript + * @default DYNAMIC * @enum {string} */ - type: "UPDATE_CONTACT"; - config: { - updates?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - subscribed?: boolean; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + type: "DYNAMIC" | "STATIC"; }; - /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ - WorkflowSendAtOptimalTimeStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Acknowledgement that a segment was deleted. */ + SegmentV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "SEND_AT_OPTIMAL_TIME"; - config: { - /** Format: uuid */ - templateId?: string; - fallbackHour?: number; - maxDelayHours?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; }; - /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ - WorkflowCloneV1: { - /** @description Name for the copy. Defaults to `Copy of `. */ - name?: string; + /** @description Cursor-paginated list of segments. */ + SegmentV1List: { + data: components["schemas"]["SegmentV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ - WorkflowStateChangeV1: { - workflow: components["schemas"]["WorkflowV1"]; - /** @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). */ - cancelled_executions: number; + /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ + SegmentV1Update: { + condition?: components["schemas"]["FilterConditionV1"]; + description?: string; + name?: string; + track_membership?: boolean; }; - /** @description Receipt for a single transactional send. */ - EmailV1: { - /** - * Format: uuid - * @description The Email row this send created. Quote it in support requests. - */ - id: string; - /** - * @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 {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; - /** - * Format: email - * @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. - */ - from: string; - }; - /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ - SendEmailV1: { - subject?: string; + /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ + SendEmail: { + attachments?: { + content: string; + contentId?: string; + contentType: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + filename: string; + }[]; + bcc?: string[]; body?: string; - /** Format: uuid */ - template?: string; - subscribed?: boolean; - name?: string; + cc?: string[]; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; from?: string | { - name?: string; /** Format: email */ email: string; + name?: string; }; - /** Format: email */ - reply?: string; headers?: { [key: string]: string; }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + name?: string; + /** Format: email */ + reply?: string; + subject?: string; + subscribed?: boolean; + tags?: string[]; + /** Format: uuid */ + template?: string; + to: string | { + /** Format: email */ + email: string; + name?: string; + } | (string | { + /** Format: email */ + email: string; + name?: string; + })[]; + }; + /** @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`. */ + SendEmailData: { + emails: components["schemas"]["SendEmailRecipientResult"][]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ + SendEmailRecipientResult: { + contact: { + /** Format: email */ + email: string; + /** Format: uuid */ + id: string; }; + /** Format: uuid */ + email: string; + }; + /** @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. */ + SendEmailResponse: { + data: components["schemas"]["SendEmailData"]; + /** @enum {boolean} */ + success: true; + }; + /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ + SendEmailV1: { attachments?: { - filename: string; content: string; - contentType: string; contentId?: string; + contentType: string; /** * @default attachment * @enum {string} */ disposition: "attachment" | "inline"; + filename: string; }[]; - tags?: string[]; - cc?: string[]; bcc?: string[]; + body?: string; + cc?: string[]; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + from?: string | { + /** Format: email */ + email: string; + name?: string; + }; + headers?: { + [key: string]: string; + }; + name?: string; + /** Format: email */ + reply?: string; + subject?: string; + subscribed?: boolean; + tags?: string[]; + /** Format: uuid */ + template?: string; /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ to: string | { - name?: string; /** Format: email */ email: string; + name?: string; }; }; - /** @description Receipt for a sandbox test send. */ - EmailTestV1: { - /** - * Format: uuid - * @description The Email row this send created. - */ - id: string; - /** - * @description Delivery status at the moment of the response — `PENDING` for a send still queued. - * @enum {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; - /** - * Format: email - * @description This project's sandbox sender — resolved server-side, never from the body. - */ - from: string; - /** - * @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 {boolean} - */ - sandbox: true; - }; /** @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. */ SendTestEmailV1: { + /** @description HTML body. Merge tags are rendered as on any other send. */ + body: string; + /** @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. */ + from?: string; + subject: string; /** * Format: email * @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. */ to?: string; - subject: string; - /** @description HTML body. Merge tags are rendered as on any other send. */ - body: string; - /** @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. */ - from?: string; - }; - /** @description Cursor-paginated list of contacts. */ - ContactV1List: { - data: components["schemas"]["ContactV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; }; - /** @description A contact as exposed on the v1 API. */ - ContactV1: { + /** + * @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 {string} + */ + SendingStream: "TRANSACTIONAL" | "MARKETING"; + /** @description A reusable fragment of template markup. */ + Snippet: { + /** @description Template markup. Values it interpolates are escaped like any other. */ + body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + description?: string | null; /** Format: uuid */ id: string; - email: string; - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description Body for POST /api/v1/contacts. */ - ContactV1Create: { - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; - /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ - custom_fields?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description The literal identifier a template includes with `{{> name}}`. */ + name: string; + /** Format: uuid */ + projectId: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; }; - /** @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. */ - ContactV1Update: { - subscribed?: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Cursor-paginated list of snippets. */ + SnippetListResponse: { + data: { + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + data: components["schemas"]["Snippet"][]; + hasMore: boolean; + total: number; + }; + /** @enum {boolean} */ + success: true; }; - /** @description Acknowledgement that a contact was deleted. */ - ContactV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Bare success envelope with no payload. */ + SuccessEmpty: { /** @enum {boolean} */ - deleted: true; + success: true; }; - /** @description Body for POST /api/lists/{id}/subscribe. */ - ListSubscribe: { + /** @description A single suppressed-email record. */ + Suppression: { + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; /** Format: email */ email: string; - /** @description Custom fields to upsert onto the contact as part of subscribing. */ - data?: { - [key: string]: unknown; - }; - /** - * @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. - * @default false - */ - allowResubscribe: boolean; - }; - /** @description Body for POST /api/lists/{id}/unsubscribe. */ - ListUnsubscribe: { - /** Format: email */ - email: string; - }; - /** @description Cursor-paginated list of subscriber lists. */ - ListV1List: { - data: components["schemas"]["ListV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description A subscriber list as exposed on the v1 API. */ - ListV1: { /** Format: uuid */ id: string; - name: string; - description: string | null; - double_opt_in: boolean; /** Format: uuid */ - confirmation_template_id: string | null; - redirect_url: string | null; - /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ - member_count: number; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; + projectId: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD"; }; - /** @description Body for POST /api/v1/lists. */ - ListV1Create: { - name: string; - description?: string | null; - /** - * @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. - * @default false - */ - double_opt_in: boolean; - /** Format: uuid */ - confirmation_template_id?: string | null; + /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ + SuppressionCheckResponse: { /** - * Format: uri - * @description Where a confirmed contact is sent after following the confirmation link. + * Format: date-time + * @description ISO 8601 datetime string */ - redirect_url?: string | null; - }; - /** @description Body for PATCH /api/v1/lists/{id}. */ - ListV1Update: { - name?: string; - description?: string | null; - double_opt_in?: boolean; - /** Format: uuid */ - confirmation_template_id?: string | null; - /** Format: uri */ - redirect_url?: string | null; + createdAt?: string; + /** @enum {string} */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + suppressed: boolean; }; - /** @description Acknowledgement that a list was deleted. */ - ListV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Cursor-paginated list of suppressions. */ + SuppressionListResponse: { + cursor?: string | null; + data: components["schemas"]["Suppression"][]; + hasMore?: boolean; + nextCursor?: string | null; /** @enum {boolean} */ - deleted: true; - }; - /** @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. */ - AddDomainBody: { - /** Format: uuid */ - projectId?: string; - domain: string; - /** - * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. - * @enum {string} - */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - stream?: components["schemas"]["SendingStream"]; - /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ - streamDefault?: boolean; - }; - /** - * @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 {string} - */ - SendingStream: "TRANSACTIONAL" | "MARKETING"; - /** @description Body for PATCH /api/domains/{id}. */ - AssignDomainStream: { - /** - * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. - * @enum {string|null} - */ - stream?: "TRANSACTIONAL" | "MARKETING" | null; - /** @description Make this the project's default identity for its stream, demoting whichever held it. */ - streamDefault?: boolean; - /** - * Format: email - * @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. - */ - defaultFromAddress?: string | null; - }; - /** @description Cursor-paginated list of sending domains. */ - DomainV1List: { - data: components["schemas"]["DomainV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + success: true; }; - /** @description A sending domain as exposed on the v1 API. */ - DomainV1: { - /** Format: uuid */ - id: string; - domain: string; - verified: boolean; - region: string | null; - stream: components["schemas"]["SendingStream"] & (string | null); - stream_default: boolean; - default_from_address: string | null; - mail_from_domain: string | null; - /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ - mail_from_domain_status: string | null; - dkim_verified: boolean; + /** @description A suppressed address as exposed on the v1 API. */ + SuppressionV1: { /** Format: date-time */ created_at: string; - /** Format: date-time */ - updated_at: string; + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + source: string; }; - /** @description Body for POST /api/v1/domains. */ - DomainV1Create: { - domain: string; + /** @description Body for POST /api/v1/suppressions. */ + SuppressionV1Create: { + /** Format: email */ + email: string; /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @default MANUAL * @enum {string} */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - stream?: components["schemas"]["SendingStream"] & unknown; - /** @description Make this the project's default identity for `stream`. Requires `stream`. */ - stream_default?: boolean; + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; - /** @description Acknowledgement that a sending domain was removed. */ - DomainV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Acknowledgement that an address was un-suppressed. */ + SuppressionV1Deleted: { /** @enum {boolean} */ deleted: true; + email: string; }; - /** @description Body for POST /api/templates. */ - CreateTemplate: { - name: string; - description?: string; - subject: string; + /** @description Cursor-paginated list of suppressed addresses. */ + SuppressionV1List: { + data: components["schemas"]["SuppressionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A reusable email template. */ + Template: { body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + description?: string | null; + /** @enum {string} */ + emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ from: string; fromName?: string | null; + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + projectId: string; /** Format: email */ replyTo?: string | null; + subject: string; /** - * @default MARKETING - * @enum {string} + * Format: date-time + * @description ISO 8601 datetime string */ - emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - }; - /** @description Body for PATCH /api/templates/{id}. */ - UpdateTemplate: { - name?: string; - description?: string; - subject?: string; - body?: string; - /** Format: email */ - from?: string; - fromName?: string | null; - /** Format: email */ - replyTo?: string | null; - /** @enum {string} */ - emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + updatedAt: string; }; /** @description Cursor-paginated list of templates. */ - TemplateV1List: { - data: components["schemas"]["TemplateV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + TemplateListResponse: { + data: { + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + data: components["schemas"]["Template"][]; + hasMore: boolean; + total: number; + }; + /** @enum {boolean} */ + success: true; }; /** @description An email template as exposed on the v1 API. */ TemplateV1: { - /** Format: uuid */ - id: string; - name: string; - description: string | null; - subject: string; body: string; + /** Format: date-time */ + created_at: string; + description: string | null; + /** @enum {string} */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; from: string; from_name: string | null; + /** Format: uuid */ + id: string; + name: string; reply_to: string | null; - /** @enum {string} */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - version: number; - /** Format: date-time */ - created_at: string; + subject: string; /** Format: date-time */ updated_at: string; + version: number; }; /** @description Body for POST /api/v1/templates. */ TemplateV1Create: { - name: string; - description?: string | null; - subject: string; body: string; + description?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** * Format: email * @description Sender address. Its domain must be verified for this project. */ from: string; from_name?: string | null; + name: string; /** Format: email */ reply_to?: string | null; - /** - * @default MARKETING - * @enum {string} - */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + subject: string; + }; + /** @description Acknowledgement that a template was deleted. */ + TemplateV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of templates. */ + TemplateV1List: { + data: components["schemas"]["TemplateV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; /** @description Body for PATCH /api/v1/templates/{id}. */ TemplateV1Update: { - name?: string; - description?: string | null; - subject?: string; body?: string; + description?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ from?: string; from_name?: string | null; + name?: string; /** Format: email */ reply_to?: string | null; - /** @enum {string} */ - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - }; - /** @description Acknowledgement that a template was deleted. */ - TemplateV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; - }; - /** @description Body for POST /api/snippets. */ - CreateSnippet: { - name: string; - description?: string | null; - body: string; + subject?: string; }; - /** @description Body for PATCH /api/snippets/{id}. */ - UpdateSnippet: { - name?: string; + TopicCreateV1: { + default_opt_in?: boolean; description?: string | null; - body?: string; - }; - /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ - CreateWebhook: { - /** Format: uri */ - url: string; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - }; - /** @description Body for PATCH /api/webhooks/{id}. */ - UpdateWebhook: { - /** Format: uri */ - url?: string; - eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** @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. */ + key: string; + name: string; }; - /** @description Cursor-paginated list of webhook endpoints. */ - WebhookV1List: { - data: components["schemas"]["WebhookV1"][]; + /** @description One page of the subjects this project mails about. */ + TopicListV1: { + data: components["schemas"]["TopicV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @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. */ - WebhookV1: { - /** Format: uuid */ - id: string; - url: string; - event_types: string[]; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description A newly created webhook and its one-time signing secret. */ - WebhookV1Created: { - webhook: components["schemas"]["WebhookV1"]; - /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ - secret: string; - }; - /** @description Body for POST /api/v1/webhooks. */ - WebhookV1Create: { - /** Format: uri */ - url: string; - event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - }; - /** @description Body for PATCH /api/v1/webhooks/{id}. */ - WebhookV1Update: { - /** Format: uri */ - url?: string; - event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; - }; - /** @description Acknowledgement that a webhook was deleted. */ - WebhookV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; - }; - /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ - WebhookV1SecretRotated: { - /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ - secret: string; - /** - * Format: date-time - * @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. - */ - previous_secret_expires_at: string; - }; - CreateApiKeyBody: { - name: string; - /** @enum {string} */ - legacyGrantPreset?: "FULL" | "SENDING_ONLY"; - /** - * @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 {string} - */ - mode?: "LIVE" | "TEST"; - /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ - scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + TopicSubscribeV1: { /** Format: uuid */ - domainId?: string | null; + contact_id: string; + /** @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. */ + subscribed: boolean; }; - /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ - AddSuppression: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** + * @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 {string} + */ + TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; + TopicSubscriptionV1: { + /** @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. */ + confirmation_url: string | null; + /** Format: date-time */ + confirmed_at: string | null; + contact_id: string; + status: components["schemas"]["TopicSubscriptionStatusV1"]; + topic_id: string; }; - /** @description Cursor-paginated list of suppressed addresses. */ - SuppressionV1List: { - data: components["schemas"]["SuppressionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ + TopicUpdateV1: { + archived?: boolean; + default_opt_in?: boolean; + description?: string | null; + name?: string; }; - /** @description A suppressed address as exposed on the v1 API. */ - SuppressionV1: { - email: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - source: string; + /** @description One subject this project mails about. */ + TopicV1: { + /** @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. */ + archived: boolean; /** Format: date-time */ created_at: string; - }; - /** @description Body for POST /api/v1/suppressions. */ - SuppressionV1Create: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; - /** @description Acknowledgement that an address was un-suppressed. */ - SuppressionV1Deleted: { - email: string; - /** @enum {boolean} */ - deleted: true; + /** @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`. */ + default_opt_in: boolean; + description: string | null; + id: string; + /** @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. */ + key: string; + name: string; + /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ + subscribed_count: number; + unsubscribed_count: number; }; /** @description Body for POST /api/track — record a custom event for a contact. */ TrackEvent: { - event: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; /** Format: email */ email: string; + event: string; subscribed?: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { + }; + /** @description Response from POST /api/track. */ + TrackEventResponse: { + data: { + /** Format: uuid */ + contact: string; + /** Format: uuid */ + event: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ + UpdateContactBody: { + customFields?: { [key: string]: unknown; }; + subscribed?: boolean; }; - /** @description Cursor-paginated list of events, newest first. */ - EventV1List: { - data: components["schemas"]["EventV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description Body for PATCH /api/snippets/{id}. */ + UpdateSnippet: { + body?: string; + description?: string | null; + name?: string; }; - /** @description A recorded custom event. */ - EventV1: { + /** @description Body for PATCH /api/templates/{id}. */ + UpdateTemplate: { + body?: string; + description?: string; + /** @enum {string} */ + emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** Format: email */ + from?: string; + fromName?: string | null; + name?: string; + /** Format: email */ + replyTo?: string | null; + subject?: string; + }; + /** @description Body for PATCH /api/webhooks/{id}. */ + UpdateWebhook: { + eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: uri */ + url?: string; + }; + /** @description Current email usage against the limits that are actually enforced. */ + UsageV1: { + daily: { + /** @description Today's sends. Null when the counter could not be read. */ + emails_sent: number | null; + limit: number; + /** @enum {string} */ + trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + }; + monthly: { + categories: { + campaign: { + emails_sent: number; + limit: number | null; + }; + inbound: { + emails_sent: number; + limit: number | null; + }; + transactional: { + emails_sent: number; + limit: number | null; + }; + workflow: { + emails_sent: number; + limit: number | null; + }; + }; + emails_sent: number; + /** @description Monthly cap on the total. Null when per-category limits govern instead. */ + limit: number | null; + }; + /** + * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. + * @enum {string} + */ + plan: "free" | "pro" | "custom"; + }; + /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ + VerifyEmail: { + /** Format: email */ + email: string; + }; + /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ + VerifyEmailResponse: { + data: { + /** Format: email */ + email: string; + reason?: string; + valid: boolean; + } & { + [key: string]: unknown; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description A user-managed outbound webhook. */ + Webhook: { + consecutiveFailures: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + disabledAt?: string | null; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; /** Format: uuid */ id: string; - name: string; + lastFour?: string; /** Format: uuid */ - contact_id: string | null; + projectId: string; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + /** Format: uri */ + url: string; + }; + /** @description An attempted webhook delivery. */ + WebhookCall: { + attempt: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + eventType: string; /** Format: uuid */ - email_id: string | null; - /** @description The payload recorded with the event, or null. */ + id: string; payload: { [key: string]: unknown; - } | null; - /** Format: date-time */ - created_at: string; + }; + responseBody?: string | null; + responseStatus?: number | null; + /** @enum {string} */ + status: "PENDING" | "SUCCESS" | "FAILED"; + /** Format: uuid */ + webhookId: string; }; - /** @description Body for POST /api/v1/events. */ - EventTrackV1: { - /** @description Event name, e.g. `user.signup`. */ - name: string; - /** - * Format: uuid - * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. - */ - contact_id?: string; - /** @description Arbitrary event payload. */ - payload?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Cursor-paginated list of recent calls for a single webhook. */ + WebhookCallsListResponse: { + cursor?: string | null; + data: components["schemas"]["WebhookCall"][]; + hasMore?: boolean; + nextCursor?: string | null; + /** @enum {boolean} */ + success: true; + }; + /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ + WebhookCreateResponse: { + /** @description A user-managed outbound webhook. */ + data: components["schemas"]["Webhook"] & { + /** @description Plaintext shared secret. Returned ONCE on create. */ + secret: string; }; + /** @enum {boolean} */ + success: true; }; - /** @description Every distinct event name in the project, most frequent first. */ - EventNamesV1: { - data: string[]; + /** @description Single webhook (no secret). */ + WebhookGetResponse: { + data: components["schemas"]["Webhook"]; + /** @enum {boolean} */ + success: true; }; - /** @description Per-name event counts over the applied window. */ - EventStatsV1: { + /** @description List of webhooks for the auth'd project. */ + WebhookListResponse: { + data: components["schemas"]["Webhook"][]; + /** @enum {boolean} */ + success: true; + }; + /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ + WebhookRotateSecretResponse: { data: { - name: string; - count: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** Format: uuid */ + id: string; + /** @description New plaintext shared secret. */ + secret: string; + }; + /** @enum {boolean} */ + success: true; }; - /** @description The time range this response was computed over, after the 90-day clamp. */ - AnalyticsWindowV1: { + /** @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. */ + WebhookV1: { /** Format: date-time */ - from: string; + created_at: string; + event_types: string[]; + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; /** Format: date-time */ - to: string; + updated_at: string; + url: string; }; - /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ - AnalyticsTimeseriesV1: { - data: { - /** Format: date-time */ - date: string; - emails: number; - delivered: number; - opens: number; - clicks: number; - bounces: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description Body for POST /api/v1/webhooks. */ + WebhookV1Create: { + event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** Format: uri */ + url: string; }; - /** @description Campaign counters and engagement over the window. */ - AnalyticsCampaignStatsV1: { - total: number; - /** @description Campaigns in DRAFT or SCHEDULED. */ - active: number; - completed: number; - /** @description Percentage, one decimal place. */ - average_open_rate: number; - average_click_rate: number; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description A newly created webhook and its one-time signing secret. */ + WebhookV1Created: { + /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + webhook: components["schemas"]["WebhookV1"]; }; - /** @description Sent campaigns ranked by open rate. */ - AnalyticsTopCampaignsV1: { - data: { - /** Format: uuid */ - id: string; - subject: string; - sent: number; - opened: number; - clicked: number; - open_rate: number; - click_rate: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description Acknowledgement that a webhook was deleted. */ + WebhookV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; }; - /** @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. */ - DeliverabilityDiagnosisV1: { - domain: string; - address: string | null; - /** Format: date-time */ - checked_at: string; - identity: components["schemas"]["DeliverabilityIdentityV1"]; - suppression: components["schemas"]["DeliverabilitySuppressionV1"]; - recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; - /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ - findings: components["schemas"]["DeliverabilityFindingV1"][]; + /** @description Cursor-paginated list of webhook endpoints. */ + WebhookV1List: { + data: components["schemas"]["WebhookV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description The sending identity's DNS health, as last refreshed. */ - DeliverabilityIdentityV1: { - /** @description Whether this project has a domain record at all. False makes every other field null. */ - registered: boolean; - verified: boolean; + /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ + WebhookV1SecretRotated: { /** - * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. - * @enum {string|null} + * Format: date-time + * @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. */ - dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + previous_secret_expires_at: string; + /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + }; + /** @description Body for PATCH /api/v1/webhooks/{id}. */ + WebhookV1Update: { + event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: uri */ + url?: string; + }; + /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ + WorkflowCloneV1: { + /** @description Name for the copy. Defaults to `Copy of `. */ + name?: string; + }; + /** @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. */ + WorkflowConditionStepV1: { + config: { + branches?: ({ + id: string; + name: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + })[]; + field?: string; + /** @enum {string} */ + mode?: "multi"; + /** @enum {string} */ + operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * @description SPF alignment for the sending identity. - * @enum {string|null} + * Format: uuid + * @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. */ - spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * @description The DMARC policy published at `_dmarc.`. - * @enum {string|null} + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + template_id?: string | null; /** - * @description Inbound receiving only. Null unless the domain has receiving enabled. - * @enum {string|null} + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; - mail_from_domain: string | null; - /** @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. */ - mail_from_domain_status: string | null; + type: "CONDITION"; + }; + /** @description Body for POST /api/v1/workflows. */ + WorkflowCreateV1: { + allow_reentry?: boolean; + description?: string; + /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ + enabled?: boolean; + /** @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. */ + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + name: string; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; + }; + /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ + WorkflowDelayStepV1: { + config: { + amount?: number; + /** @enum {string} */ + unit?: "minutes" | "hours" | "days"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * Format: date-time - * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. + * Format: uuid + * @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. */ - last_checked_at: string | null; - }; - /** @description Null unless the request named an `address`. */ - DeliverabilitySuppressionV1: { - suppressed: boolean; - /** @enum {string|null} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; - /** @enum {string|null} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; - /** Format: date-time */ - suppressed_at: string | null; - } | null; - /** @description Delivery outcomes over the requested window. */ - DeliverabilityRecentDeliveryV1: { - window_days: number; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * @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 {string} + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - scope: "project"; - sent: number; - delivered: number; - bounced: number; - complained: number; - failed: number; - /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ - bounce_rate: number | null; - complaint_rate: number | null; - }; - /** @description One diagnosed problem, with its fix. */ - DeliverabilityFindingV1: { - /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ - code: string; - severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; - /** @description What is wrong, in one sentence. */ - summary: string; - /** @description What to do about it. */ - remedy: string; - }; - /** - * @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 {string} - */ - DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; - /** @description Cursor-paginated recipient-domain rollup, newest day first. */ - RecipientDomainStatsV1List: { - data: components["schemas"]["RecipientDomainStatsV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description Delivery outcomes for one recipient domain on one day. */ - RecipientDomainStatsV1: { - /** @description The recipient's domain, lowercased: the part after the `@`. */ - domain: string; - /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ - day: string; - sent: number; - delivered: number; - bounced: number; - complained: number; - opened: number; + template_id?: string | null; /** - * Format: date-time - * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - computed_at: string; - }; - /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ - DmarcReportV1List: { - data: components["schemas"]["DmarcReportV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + type: "DELAY"; }; - /** @description One DMARC aggregate (RUA) report. */ - DmarcReportV1: { + /** @description Confirmation that a workflow was deleted. */ + WorkflowDeletedV1: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ id: string; - /** @description The receiver's own id for this report. */ - report_id: string; - /** @description The reporting receiver, e.g. `google.com`. */ - org_name: string; - /** @description The domain of yours the report is about. */ - policy_domain: string; - /** Format: date-time */ - range_begin: string; - /** Format: date-time */ - range_end: string; - total_count: number; - /** @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. */ - pass_count: number; - fail_count: number; - /** @description Per-sending-source rows, as the receiver reported them. */ - sources: { - source_ip: string; - count: number; - disposition: string; - dkim: string; - spf: string; - header_from: string; - }[]; - /** Format: date-time */ - received_at: string; }; - /** @description Current email usage against the limits that are actually enforced. */ - UsageV1: { + /** @description Body for POST /api/v1/workflows/{id}/executions. */ + WorkflowExecutionStartV1: { /** - * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. - * @enum {string} + * Format: uuid + * @description Contact to enter the workflow. Must belong to this project. */ - plan: "free" | "pro" | "custom"; - monthly: { - emails_sent: number; - /** @description Monthly cap on the total. Null when per-category limits govern instead. */ - limit: number | null; - categories: { - transactional: { - emails_sent: number; - limit: number | null; - }; - campaign: { - emails_sent: number; - limit: number | null; - }; - workflow: { - emails_sent: number; - limit: number | null; - }; - inbound: { - emails_sent: number; - limit: number | null; - }; - }; - }; - daily: { - /** @description Today's sends. Null when the counter could not be read. */ - emails_sent: number | null; - limit: number; - /** @enum {string} */ - trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + contact_id: string; + /** @description Extra variables merged into the contact's data for this run. */ + context?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; }; - /** @description The project the presented credential is scoped to. */ - ProjectV1: { + /** @description One contact's run through a workflow. */ + WorkflowExecutionV1: { + /** Format: date-time */ + completed_at: string | null; + /** Format: uuid */ + contact_id: string; + /** Format: uuid */ + current_step_id: string | null; + exit_reason: string | null; /** Format: uuid */ id: string; - name: string; - /** @description A disabled project sends nothing; every send is refused. */ - disabled: boolean; - /** @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. */ - sandbox_address: string | null; - /** @description Locked once the first domain is added. */ - ses_region: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; /** Format: date-time */ - created_at: string; - }; - /** @description A receiving mailbox on one of the project's verified domains. */ - Mailbox: { + started_at: string; + /** @enum {string} */ + status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; /** Format: uuid */ + workflow_id: string; + }; + /** @description Cursor-paginated list of workflow executions, newest first. */ + WorkflowExecutionV1List: { + data: components["schemas"]["WorkflowExecutionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Ends the run early and stamps `exit_reason`. */ + WorkflowExitStepV1: { + config: { + reason?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: email - * @description The full mailbox address, e.g. `support@superbooks.io`. + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - address: string; - displayName: string | null; + template_id?: string | null; /** - * @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 discriminator enum property added by openapi-typescript * @enum {string} */ - status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; - /** @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. */ - quotaBytes: number | null; + type: "EXIT"; + }; + /** @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. */ + WorkflowGraphReplaceV1: { + /** @description The complete step set. Exactly one must be a `TRIGGER`. */ + steps: components["schemas"]["WorkflowStepV1"][]; + /** @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. */ + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @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. */ + WorkflowGraphV1: { + steps: components["schemas"]["WorkflowStepReadV1"][]; + transitions: components["schemas"]["WorkflowTransitionV1"][]; + /** @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. */ + version: number; + /** Format: uuid */ + workflow_id: string; + }; + /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ + WorkflowSendAtOptimalTimeStepV1: { + config: { + fallbackHour?: number; + maxDelayHours?: number; + /** Format: uuid */ + templateId?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** * Format: uuid - * @description The verified domain this mailbox lives on. + * @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. */ - domainId: string; - /** Format: date-time */ - createdAt: string; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_AT_OPTIMAL_TIME"; }; - /** @description A mailbox plus its IMAP/SMTP connection settings. */ - MailboxDetail: components["schemas"]["Mailbox"] & { - /** @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. */ - settings: { - imap: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; - }; - smtp: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ + WorkflowSendEmailStepV1: { + config: { + body?: string; + recipient?: { + /** Format: email */ + customEmail?: string; + /** @enum {string} */ + type: "CONTACT" | "CUSTOM"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; + subject?: string; + /** Format: uuid */ + templateId?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; - }; - CreateMailboxBody: { /** * Format: uuid - * @description Defaults to the project the credential resolves to. Naming a different one is refused. + * @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. */ - projectId?: string; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** * Format: uuid - * @description A VERIFIED domain belonging to this project. + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - domainId: string; - /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ - localPart: string; - displayName?: string; - /** @description NOT IMPLEMENTED — sending any value answers 400. */ - quotaBytes?: number; + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_EMAIL"; }; - /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ - DraftMailboxMessage: { - /** @enum {string} */ - mode: "draft" | "rewrite" | "subject"; - brief?: string; - draft?: string; - instruction?: string; - /** @enum {string} */ - tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; - recipientContext?: string; - senderAddress?: string; + /** + * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @enum {string} + */ + WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @description One step of a linear workflow sequence. */ + WorkflowSequenceStepV1: { + /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description Human-readable label, e.g. `Day 0: welcome`. */ + name: string; + /** + * Format: uuid + * @description For `SEND_EMAIL`: a template in this project. + */ + template_id?: string; + type: components["schemas"]["WorkflowSequenceStepTypeV1"]; }; - /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ - ComposeMailboxMessage: { - to: string[]; - cc?: string[]; - bcc?: string[]; - subject: string; - body: string; + /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ + WorkflowStateChangeV1: { + /** @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). */ + cancelled_executions: number; + workflow: components["schemas"]["WorkflowV1"]; + }; + /** @description Execution, email and conversion totals for one workflow. */ + WorkflowStatsV1: { + avg_duration_ms: number | null; + /** @description Execution counts keyed by status; a status with no executions is absent. */ + by_status: { + [key: string]: number; + }; + /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ + completion_rate: number | null; + conversions: { + count: number; + event_name: string; + /** Format: uuid */ + goal_id: string; + name: string; + }[]; + emails: { + clicked: number; + opened: number; + sent: number; + }; + enabled: boolean; + name: string; + /** @description Steps in the workflow's graph, trigger step included. */ + step_count: number; + total: number; + trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + /** Format: uuid */ + workflow_id: string; + }; + /** @description Where this step sits on the editor canvas. */ + WorkflowStepPositionV1: { + x: number; + y: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; - /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ - AppPassword: { - /** Format: uuid */ + /** @description One node of a workflow graph, as read. */ + WorkflowStepReadV1: { + /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; - /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ name: string; - /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ - scopes: ("imap" | "smtp")[]; - /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ - lastFour: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: date-time - * @description Null until a mail client has authenticated with it at least once. + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - lastUsedAt: string | null; - /** Format: date-time */ - createdAt: string; + template_id?: string | null; + /** @enum {string} */ + type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; }; - /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ - AppPasswordReveal: { + /** @description One node of a workflow graph. */ + WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; + /** @description One directed edge between two steps. */ + WorkflowTransitionV1: { + /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ + condition: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; /** Format: uuid */ - id: string; - /** - * Format: uri - * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. - */ - revealUrl: string; + from_step_id: string; /** - * Format: date-time - * @description When the link stops working. Five minutes after creation; the password itself does not expire. + * Format: uuid + * @description Caller-chosen on a write, exactly like a step id. */ - revealExpiresAt: string; + id: string; + /** @description Evaluation order among the edges leaving one step; lowest first. */ + priority: number; + /** Format: uuid */ + to_step_id: string; }; - /** @description Body for POST /api/mailboxes/:id/app-passwords. */ - CreateAppPassword: { - name: string; + /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ + WorkflowTriggerStepV1: { + config: { + eventName?: string; + intervalMs?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * @default [ - * "imap", - * "smtp" - * ] + * Format: uuid + * @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. */ - scopes: ("imap" | "smtp")[]; - }; - /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ - VerifyEmail: { - /** Format: email */ - email: string; - }; - TopicListV1: { - data: components["schemas"]["TopicV1"][]; - cursor: string | null; - has_more: boolean; - }; - /** @description One subject this project mails about. */ - TopicV1: { id: string; - /** @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. */ - key: string; - name: string; - description: string | null; - /** @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`. */ - default_opt_in: boolean; - /** @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. */ - archived: boolean; - /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ - subscribed_count: number; - unsubscribed_count: number; - /** Format: date-time */ - created_at: string; - }; - TopicCreateV1: { - /** @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. */ - key: string; name: string; - description?: string | null; - default_opt_in?: boolean; - }; - /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ - TopicUpdateV1: { - name?: string; - description?: string | null; - default_opt_in?: boolean; - archived?: boolean; - }; - TopicSubscriptionV1: { - topic_id: string; - contact_id: string; - status: components["schemas"]["TopicSubscriptionStatusV1"]; - /** Format: date-time */ - confirmed_at: string | null; - /** @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. */ - confirmation_url: string | null; - }; - /** - * @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 {string} - */ - TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; - TopicSubscribeV1: { - /** Format: uuid */ - contact_id: string; - /** @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. */ - subscribed: boolean; - }; - /** @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. */ - ContactTopicPreferencesV1: { - contact_id: string; - /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ - subscribed: boolean; - topics: { - topic_id: string; - /** @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. */ - key: string; - name: string; - /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ - subscribed: boolean; - pending: boolean; - }[]; - }; - /** @description One verdict per address, in the order they were given. */ - EmailValidationBatchV1: { - results: components["schemas"]["EmailValidationV1"][]; - }; - /** @description One address's verdict, with the evidence behind it. */ - EmailValidationV1: { - email: string; - verdict: components["schemas"]["EmailValidationVerdictV1"]; - /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ - is_disposable: boolean; - /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ - is_role_address: boolean; - /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ - is_personal: boolean; - /** @description The domain publishes MX records. */ - has_mx_records: boolean; - /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ - reasons: string[]; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "TRIGGER"; }; /** - * @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`. + * @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 {string} */ - EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; - EmailValidationBatchRequestV1: { - /** @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. */ - emails: string[]; - }; - /** @description One bulk validation run over a list. */ - EmailValidationRunV1: { + WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ + WorkflowUpdateContactStepV1: { + config: { + subscribed?: boolean; + updates?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; - list_id: string | null; - /** @enum {string} */ - status: "pending" | "running" | "completed" | "failed"; - /** @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. */ - processed_count: number; - deliverable_count: number; - undeliverable_count: number; - risky_count: number; - /** Format: date-time */ - started_at: string | null; - /** Format: date-time */ - completed_at: string | null; - /** @description Set only on `failed`. Prose for an operator; never parse it. */ - failure_reason: string | null; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "UPDATE_CONTACT"; + }; + /** @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. */ + WorkflowUpdateV1: { + allow_reentry?: boolean; + description?: string; + enabled?: boolean; + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + /** @description Per-workflow start rate cap. `null` removes the cap. */ + max_executions_per_hour?: number | null; + name?: string; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + }; + /** @description An automation workflow as exposed on the v1 API. */ + WorkflowV1: { + allow_reentry: boolean; /** Format: date-time */ created_at: string; + description: string | null; + enabled: boolean; + /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ + event_name: string | null; + /** Format: uuid */ + id: string; + max_executions_per_hour: number | null; + name: string; + /** @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. */ + step_count: number; + /** @enum {string} */ + trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; + /** Format: date-time */ + updated_at: string; + /** @description Incremented on every structural (step/transition) change. */ + version: number; }; - /** @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. */ - EmailValidationResultListV1: { - data: (components["schemas"]["EmailValidationV1"] & { - contact_id: string | null; - })[]; - /** @description Pass as `cursor` for the next page; null on the last. */ - cursor: string | null; + /** @description Cursor-paginated list of workflows. */ + WorkflowV1List: { + data: components["schemas"]["WorkflowV1"][]; has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ + WorkflowWaitForEventStepV1: { + config: { + eventName?: string; + timeout?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WAIT_FOR_EVENT"; + }; + /** @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. */ + WorkflowWebhookStepV1: { + config: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + body?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + headers?: { + [key: string]: string; + }; + /** @enum {string} */ + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Format: uri */ + url?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WEBHOOK"; }; }; responses: never; @@ -5235,12 +5252,13 @@ interface components { pathItems: never; } interface operations { - v1ListCampaigns: { + listContacts: { parameters: { query?: { limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + cursor?: string; + search?: string; + subscribed?: "true" | "false"; }; header?: never; path?: never; @@ -5248,2822 +5266,2855 @@ interface operations { }; requestBody?: never; responses: { - /** @description Campaign list */ + /** @description Contact list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1List"]; + "application/json": components["schemas"]["ContactListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateCampaign: { + createContact: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Create"]; + "application/json": components["schemas"]["CreateContact"]; }; }; responses: { - /** @description Campaign created */ + /** @description Contact created */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `internal_error`. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - }; - }; - v1GetCampaign: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The campaign */ - 200: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Email already exists for this project */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteCampaign: { + bulkCreateContacts: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkCreateBody"]; + }; + }; responses: { - /** @description Campaign deleted */ + /** @description Bulk-create result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Deleted"]; + "application/json": { + data: { + created: number; + errors: { + index: number; + message: string; + }[]; + skipped: number; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateCampaign: { + bulkDeleteContacts: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Update"]; + "application/json": components["schemas"]["ContactBulkDeleteBody"]; }; }; responses: { - /** @description The updated campaign */ + /** @description Bulk-delete result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: { + deleted: number; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendCampaign: { + upsertContact: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path: { - /** @description Resource id. */ - id: string; - }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Send"]; + "application/json": components["schemas"]["CreateContact"]; }; }; responses: { - /** @description The campaign, now `SENDING` or `SCHEDULED` */ + /** @description Contact created or updated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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 failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelCampaign: { + getContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The cancelled campaign */ + /** @description Contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1PauseCampaign: { + deleteContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The paused campaign */ + /** @description Contact deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["IdResponse"]; }; }; - /** @description `validation_error` — only a `SENDING` campaign can be paused. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ResumeCampaign: { + updateContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateContactBody"]; + }; + }; responses: { - /** @description The resumed campaign */ + /** @description Updated contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetCampaignStats: { + listDomains: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description Domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Stats"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["DomainListResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListCampaignFailures: { + addDomain: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AddDomainBody"]; + }; + }; responses: { - /** @description Failed sends */ - 200: { + /** @description Domain added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1FailureList"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @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. */ + 502: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1RetryCampaignFailures: { + getDomain: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The retry was queued */ + /** @description Domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1RetryFailed"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a retry is already running for this campaign. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegments: { + deleteDomain: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment list */ + /** @description Domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1List"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateSegment: { + assignDomainStream: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SegmentV1Create"]; + "application/json": components["schemas"]["AssignDomainStream"]; }; }; responses: { - /** @description Segment created */ - 201: { + /** @description Updated sending identity */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetSegment: { + startDomainSetup: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The segment */ + /** @description Guided setup session */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: { + /** + * Format: uri + * @description Open this in a browser to publish the records. Short-lived and domain-specific. + */ + connectUrl: string; + /** @description When `connectUrl` stops working. */ + expiresAt: string; + token: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteSegment: { + getDomainVerification: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment deleted */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1Deleted"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["DomainVerificationStatus"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the segment is still used by one or more active campaigns. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateSegment: { + verifyDomain: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SegmentV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated segment */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: components["schemas"]["DomainVerificationStatus"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegmentContacts: { + listEmails: { parameters: { query?: { limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + cursor?: string; + tag?: string; + /** @description Delivery lifecycle of the message. Engagement is reported separately. */ + status?: components["schemas"]["EmailDeliveryStatus"]; + from?: string; }; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment member list */ + /** @description Email list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentContactV1List"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["EmailListResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflows: { + sendEmail: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; }; - header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmail"]; + }; + }; responses: { - /** @description Workflow list */ + /** @description Email accepted / sent */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1List"]; + "application/json": components["schemas"]["SendEmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateWorkflow: { + sendEmailBatch: { parameters: { query?: never; - header?: never; + header?: { + "Idempotency-Key"?: string; + }; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowCreateV1"]; + "application/json": components["schemas"]["BatchSendBody"]; }; }; responses: { - /** @description Workflow created */ - 201: { + /** @description All entries sent */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["BatchSendResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Partial success — at least one entry failed */ + 207: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflow: { + getEmail: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow */ + /** @description Email and its delivery history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["EmailDetailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteWorkflow: { + cancelScheduledEmail: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow deleted */ + /** @description Email cancelled */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowDeletedV1"]; + "application/json": components["schemas"]["EmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow still has running executions. */ - 409: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Email already past PENDING */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateWorkflow: { + subscribeToList: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description List id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowUpdateV1"]; + "application/json": components["schemas"]["ListSubscribe"]; }; }; responses: { - /** @description Updated workflow */ + /** @description Contact subscribed, or an existing membership returned unchanged */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["ListSubscribeResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the trigger cannot be changed while executions are running. */ + /** @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. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflowExecutions: { + unsubscribeFromList: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only executions in this state. */ - status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description List id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ListUnsubscribe"]; + }; + }; responses: { - /** @description Execution list */ + /** @description Contact unsubscribed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1List"]; + "application/json": components["schemas"]["ListUnsubscribeResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1StartWorkflowExecution: { + listMailboxes: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WorkflowExecutionStartV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Execution started */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Mailbox list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["Mailbox"][]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelWorkflowExecution: { + createMailbox: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow execution id. */ - execution_id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMailboxBody"]; + }; + }; responses: { - /** @description Cancelled execution */ - 200: { + /** @description Mailbox provisioned */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": { + data: components["schemas"]["Mailbox"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no execution with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflowStats: { + getMailbox: { parameters: { - query?: { - /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ - from?: string | null; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow statistics */ + /** @description Mailbox with connection settings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStatsV1"]; + "application/json": { + data: components["schemas"]["MailboxDetail"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflowGraph: { + deleteMailbox: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The workflow's graph */ + /** @description Mailbox deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowGraphV1"]; + "application/json": { + data: { + /** @enum {boolean} */ + deleted: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ReplaceWorkflowGraph: { + listAppPasswords: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; - }; - }; + requestBody?: never; responses: { - /** @description The graph as it now stands */ + /** @description App password list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowGraphV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["AppPassword"][]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CloneWorkflow: { + createAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["WorkflowCloneV1"]; + "application/json": components["schemas"]["CreateAppPassword"]; }; }; responses: { - /** @description The cloned workflow */ + /** @description App password created; the secret is behind the one-time link */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + data: components["schemas"]["AppPasswordReveal"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1PauseWorkflow: { + revokeAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; + passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The workflow, and the number of runs this call cancelled */ + /** @description App password revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStateChangeV1"]; + "application/json": { + data: { + /** @enum {boolean} */ + revoked: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ResumeWorkflow: { + draftMailboxMessage: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DraftMailboxMessage"]; + }; + }; responses: { - /** @description The workflow, with `cancelled_executions` always 0 */ + /** @description A draft. Nothing was sent. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStateChangeV1"]; + "application/json": { + data: { + /** @description Suggested plain-text body, or null. */ + body: string | null; + /** + * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. + * @enum {boolean} + */ + sent: false; + /** @description Suggested subject, or null. */ + subject: string | null; + /** @description Alternative subject lines (`subject` mode); empty otherwise. */ + subjects: string[]; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @description The drafting model was unreachable or returned nothing usable. */ + 502: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendEmail: { + sendMailboxMessage: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; + header?: never; + path: { + id: string; }; - path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SendEmailV1"]; + "application/json": components["schemas"]["ComposeMailboxMessage"]; }; }; responses: { - /** @description Email queued */ - 202: { + /** @description Message submitted */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailV1"]; + "application/json": { + data: { + /** + * Format: uuid + * @description The conversation this send started. Replies thread onto it. + */ + conversationId: string; + /** + * Format: uuid + * @description The stored outbound message. + */ + messageId: string; + /** @enum {boolean} */ + submitted: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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 The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + /** @description The mail server refused the submission. Nothing was sent. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ 503: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendTestEmail: { + listApiKeys: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SendTestEmailV1"]; + path: { + /** @description Project id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Test email queued */ - 202: { + /** @description API key list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailTestV1"]; + "application/json": components["schemas"]["ApiKeyListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - listEmails: { + createApiKey: { parameters: { - query?: { - limit?: number; - cursor?: string; - tag?: string; - /** @description Delivery lifecycle of the message. Engagement is reported separately. */ - status?: components["schemas"]["EmailDeliveryStatus"]; - from?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateApiKeyBody"]; + }; + }; responses: { - /** @description Email list */ - 200: { + /** @description API key created; the secret is behind the reveal link. */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailListResponse"]; + "application/json": { + /** @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. */ + data: components["schemas"]["ApiKey"] & { + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8113,32 +8164,30 @@ interface operations { }; }; }; - sendEmail: { + revokeApiKey: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; + header?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; }; - path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendEmail"]; - }; - }; + requestBody?: never; responses: { - /** @description Email accepted / sent */ + /** @description API key revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SendEmailResponse"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -8156,7 +8205,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -8165,17 +8214,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8201,35 +8241,45 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - getEmail: { + rotateApiKey: { parameters: { query?: never; header?: never; path: { + /** @description Project id. */ id: string; + /** @description API key id. */ + keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Email and its delivery history */ + /** @description API key rotated; the new secret is behind the reveal link. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailDetailResponse"]; + "application/json": { + data: { + lastFour: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8288,40 +8338,29 @@ interface operations { }; }; }; - sendEmailBatch: { + listSnippets: { parameters: { - query?: never; - header?: { - "Idempotency-Key"?: string; + query?: { + limit?: number; + cursor?: string; + search?: string; }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["BatchSendBody"]; - }; - }; + requestBody?: never; responses: { - /** @description All entries sent */ + /** @description Snippet list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BatchSendResponse"]; - }; - }; - /** @description Partial success — at least one entry failed */ - 207: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BatchSendResponse"]; + "application/json": components["schemas"]["SnippetListResponse"]; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -8339,7 +8378,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -8348,16 +8387,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; @@ -8384,35 +8414,32 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - cancelScheduledEmail: { + createSnippet: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSnippet"]; + }; + }; responses: { - /** @description Email cancelled */ - 200: { + /** @description Snippet created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailResponse"]; + "application/json": { + data: components["schemas"]["Snippet"]; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8442,8 +8469,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description A snippet with that name already exists in this project */ + 409: { headers: { [name: string]: unknown; }; @@ -8451,8 +8478,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already past PENDING */ - 409: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -8480,27 +8507,28 @@ interface operations { }; }; }; - listContacts: { + getSnippet: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - subscribed?: "true" | "false"; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact list */ + /** @description Snippet */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactListResponse"]; + "application/json": { + data: components["schemas"]["Snippet"]; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8530,8 +8558,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8559,30 +8587,24 @@ interface operations { }; }; }; - createContact: { + deleteSnippet: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateContact"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Contact created */ - 201: { + /** @description Snippet deleted */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -8612,17 +8634,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already exists for this project */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8650,29 +8663,31 @@ interface operations { }; }; }; - upsertContact: { + updateSnippet: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateContact"]; + "application/json": components["schemas"]["UpdateSnippet"]; }; }; responses: { - /** @description Contact created or updated */ + /** @description Updated snippet */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { + data: components["schemas"]["Snippet"]; /** @enum {boolean} */ success: true; - data: components["schemas"]["Contact"]; }; }; }; @@ -8703,6 +8718,24 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -8732,37 +8765,26 @@ interface operations { }; }; }; - bulkCreateContacts: { + listSuppressions: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactBulkCreateBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Bulk-create result */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - created: number; - skipped: number; - errors: { - index: number; - message: string; - }[]; - }; - }; + "application/json": components["schemas"]["SuppressionListResponse"]; }; }; /** @description Validation error */ @@ -8792,15 +8814,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8821,7 +8834,7 @@ interface operations { }; }; }; - bulkDeleteContacts: { + addSuppression: { parameters: { query?: never; header?: never; @@ -8830,23 +8843,17 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContactBulkDeleteBody"]; + "application/json": components["schemas"]["AddSuppression"]; }; }; responses: { - /** @description Bulk-delete result */ - 200: { + /** @description Suppression added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - deleted: number; - }; - }; + "application/json": components["schemas"]["Suppression"]; }; }; /** @description Validation error */ @@ -8876,15 +8883,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8905,28 +8903,25 @@ interface operations { }; }; }; - getContact: { + checkSuppression: { parameters: { query?: never; header?: never; path: { - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact */ + /** @description Suppression check result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["SuppressionCheckResponse"]; }; }; /** @description Validation error */ @@ -8956,15 +8951,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8985,25 +8971,24 @@ interface operations { }; }; }; - deleteContact: { + removeSuppression: { parameters: { query?: never; header?: never; path: { - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact deleted */ - 200: { + /** @description Suppression removed */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["IdResponse"]; - }; + content?: never; }; /** @description Validation error */ 400: { @@ -9032,15 +9017,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -9061,32 +9037,27 @@ interface operations { }; }; }; - updateContact: { + listTemplates: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + cursor?: string; + search?: string; + emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateContactBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated contact */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["TemplateListResponse"]; }; }; /** @description Validation error */ @@ -9116,15 +9087,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -9154,415 +9116,442 @@ interface operations { }; }; }; - v1ListContacts: { + createTemplate: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Case-insensitive substring match on the email address. */ - search?: string; - /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ - subscribed?: "true" | "false"; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTemplate"]; + }; + }; responses: { - /** @description Contact list */ - 200: { + /** @description Template created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1List"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateContact: { + getTemplate: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactV1Create"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The created contact */ - 201: { + /** @description Template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a contact with this email already exists in this project. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetContact: { + deleteTemplate: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The contact */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; + "application/json": components["schemas"]["IdResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Template still in use */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteContact: { + updateTemplate: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTemplate"]; + }; + }; responses: { - /** @description Contact deleted */ + /** @description Updated template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1Deleted"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateContact: { + trackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ContactV1Update"]; + "application/json": components["schemas"]["TrackEvent"]; }; }; responses: { - /** @description The updated contact */ + /** @description Event tracked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["TrackEventResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - subscribeToList: { + createProject: { parameters: { query?: never; header?: never; - path: { - /** @description List id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ListSubscribe"]; + "application/json": { + name: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; + }; }; }; responses: { - /** @description Contact subscribed, or an existing membership returned unchanged */ - 200: { + /** @description Project created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSubscribeResponse"]; + "application/json": components["schemas"]["ProjectRecord"]; }; }; /** @description Validation error */ @@ -9592,24 +9581,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -9639,102 +9610,83 @@ interface operations { }; }; }; - unsubscribeFromList: { + v1GetCampaignAnalytics: { parameters: { - query?: never; - header?: never; - path: { - /** @description List id. */ - id: string; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListUnsubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact unsubscribed */ + /** @description Campaign statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUnsubscribeResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListLists: { + v1GetAnalyticsTimeseries: { parameters: { query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; header?: never; path?: never; @@ -9742,13 +9694,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Subscriber lists */ + /** @description Daily time series */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1List"]; + "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9798,26 +9750,28 @@ interface operations { }; }; }; - v1CreateList: { + v1ListTopCampaigns: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + limit?: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The created list */ - 201: { + /** @description Ranked campaigns */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9867,25 +9821,26 @@ interface operations { }; }; }; - v1GetList: { + v1ListCampaigns: { parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The list */ + /** @description Campaign list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["CampaignV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9906,15 +9861,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9944,25 +9890,29 @@ interface operations { }; }; }; - v1DeleteList: { + v1CreateCampaign: { parameters: { query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Create"]; + }; + }; responses: { - /** @description List deleted */ - 200: { + /** @description Campaign created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1Deleted"]; + "application/json": components["schemas"]["CampaignV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9983,7 +9933,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ 404: { headers: { [name: string]: unknown; @@ -9992,7 +9942,16 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; @@ -10021,7 +9980,7 @@ interface operations { }; }; }; - v1UpdateList: { + v1GetCampaign: { parameters: { query?: never; header?: never; @@ -10031,19 +9990,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated list */ + /** @description The campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["CampaignV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10064,7 +10019,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -10102,662 +10057,737 @@ interface operations { }; }; }; - listDomains: { + v1DeleteCampaign: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain list */ + /** @description Campaign deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainListResponse"]; + "application/json": components["schemas"]["CampaignV1Deleted"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - addDomain: { + v1UpdateCampaign: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AddDomainBody"]; + "application/json": components["schemas"]["CampaignV1Update"]; }; }; responses: { - /** @description Domain added */ - 201: { + /** @description The updated campaign */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ - 502: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomain: { + v1CancelCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain */ + /** @description The cancelled campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteDomain: { + v1ListCampaignFailures: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain removed */ + /** @description Failed sends */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["CampaignV1FailureList"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - assignDomainStream: { + v1PauseCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AssignDomainStream"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated sending identity */ + /** @description The paused campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `SENDING` campaign can be paused. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomainVerification: { + v1ResumeCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The resumed campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["DomainVerificationStatus"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - verifyDomain: { + v1RetryCampaignFailures: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The retry was queued */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["DomainVerificationStatus"]; - }; + "application/json": components["schemas"]["CampaignV1RetryFailed"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — a retry is already running for this campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - startDomainSetup: { + v1SendCampaign: { parameters: { query?: never; - header?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CampaignV1Send"]; + }; + }; responses: { - /** @description Guided setup session */ + /** @description The campaign, now `SENDING` or `SCHEDULED` */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - token: string; - /** - * Format: uri - * @description Open this in a browser to publish the records. Short-lived and domain-specific. - */ - connectUrl: string; - /** @description When `connectUrl` stops working. */ - expiresAt: string; - }; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @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. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListDomains: { + v1GetCampaignStats: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Sending domain list */ + /** @description Campaign statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1List"]; + "application/json": components["schemas"]["CampaignV1Stats"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10778,6 +10808,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -10807,26 +10846,30 @@ interface operations { }; }; }; - v1CreateDomain: { + v1ListContacts: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the email address. */ + search?: string; + /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ + subscribed?: "true" | "false"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["DomainV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The registered sending domain, awaiting DNS */ - 201: { + /** @description Contact list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10837,18 +10880,9 @@ interface operations { content: { "application/problem+json": components["schemas"]["Problem"]; }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — this domain is already registered to a project you can send from. */ - 409: { + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -10883,36 +10917,28 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1GetDomain: { + v1CreateContact: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Create"]; + }; + }; responses: { - /** @description The sending domain */ - 200: { + /** @description The created contact */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10933,8 +10959,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ - 404: { + /** @description `conflict` — a contact with this email already exists in this project. */ + 409: { headers: { [name: string]: unknown; }; @@ -10971,7 +10997,7 @@ interface operations { }; }; }; - v1DeleteDomain: { + v1GetContact: { parameters: { query?: never; header?: never; @@ -10983,13 +11009,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Sending domain removed */ + /** @description The contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1Deleted"]; + "application/json": components["schemas"]["ContactV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11010,7 +11036,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11019,15 +11045,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -11057,7 +11074,7 @@ interface operations { }; }; }; - v1VerifyDomain: { + v1DeleteContact: { parameters: { query?: never; header?: never; @@ -11069,13 +11086,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description The sending domain, as SES and DNS now report it */ + /** @description Contact deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11096,7 +11113,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11134,434 +11151,379 @@ interface operations { }; }; }; - listTemplates: { + v1UpdateContact: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Update"]; + }; + }; responses: { - /** @description Template list */ + /** @description The updated contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateListResponse"]; + "application/json": components["schemas"]["ContactV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createTemplate: { + v1GetContactTopicPreferences: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateTemplate"]; + path: { + /** @description The contact. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Template created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description The contact's preferences */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ContactTopicPreferencesV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getTemplate: { + v1DiagnoseDeliverability: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query: { + /** @description A sending domain in this project, e.g. `example.com`. */ + domain: 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. */ + address?: string; + /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ + window_days?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Template */ + /** @description The diagnosis, with findings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteTemplate: { + v1ListDmarcReports: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ + days?: number; + /** @description Restrict to reports about one of your domains. */ + domain?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Template deleted */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["IdResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description Cursor-paginated DMARC aggregate reports */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DmarcReportV1List"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Template still in use */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateTemplate: { + v1ListRecipientDomainStats: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ + days?: number; + /** @description Restrict to one recipient domain. */ + domain?: string; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateTemplate"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated template */ + /** @description Cursor-paginated recipient-domain rollup */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["RecipientDomainStatsV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListTemplates: { + v1ListDomains: { parameters: { query?: { limit?: number; /** @description Opaque cursor from a previous response's `next_cursor`. */ after?: string; - /** @description Case-insensitive substring match on the name. */ - search?: string; - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; }; header?: never; path?: never; @@ -11569,13 +11531,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Template list */ + /** @description Sending domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1List"]; + "application/json": components["schemas"]["DomainV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11625,7 +11587,7 @@ interface operations { }; }; }; - v1CreateTemplate: { + v1CreateDomain: { parameters: { query?: never; header?: never; @@ -11634,17 +11596,17 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TemplateV1Create"]; + "application/json": components["schemas"]["DomainV1Create"]; }; }; responses: { - /** @description The created template */ + /** @description The registered sending domain, awaiting DNS */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11665,6 +11627,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `conflict` — this domain is already registered to a project you can send from. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -11692,9 +11663,18 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @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. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; }; - v1GetTemplate: { + v1GetDomain: { parameters: { query?: never; header?: never; @@ -11706,13 +11686,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description The template */ + /** @description The sending domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11733,7 +11713,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11771,7 +11751,7 @@ interface operations { }; }; }; - v1DeleteTemplate: { + v1DeleteDomain: { parameters: { query?: never; header?: never; @@ -11783,13 +11763,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Template deleted */ + /** @description Sending domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1Deleted"]; + "application/json": components["schemas"]["DomainV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11810,7 +11790,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11819,7 +11799,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ 409: { headers: { [name: string]: unknown; @@ -11857,7 +11837,7 @@ interface operations { }; }; }; - v1UpdateTemplate: { + v1VerifyDomain: { parameters: { query?: never; header?: never; @@ -11867,19 +11847,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TemplateV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated template */ + /** @description The sending domain, as SES and DNS now report it */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11900,7 +11876,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11938,85 +11914,175 @@ interface operations { }; }; }; - listSnippets: { + v1ValidateEmails: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + }; + }; responses: { - /** @description Snippet list */ + /** @description One verdict per address, in the order they were given */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SnippetListResponse"]; + "application/json": components["schemas"]["EmailValidationBatchV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmailV1"]; + }; + }; + responses: { + /** @description Email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation error */ - 400: { + /** @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. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ + /** @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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createSnippet: { + v1SendTestEmail: { parameters: { query?: never; header?: never; @@ -12025,887 +12091,825 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateSnippet"]; + "application/json": components["schemas"]["SendTestEmailV1"]; }; }; responses: { - /** @description Snippet created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description Test email queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailTestV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @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: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description A snippet with that name already exists in this project */ + /** @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. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ + /** @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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getSnippet: { + v1ListEvents: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only events with this exact name. */ + event_name?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Snippet */ + /** @description Event list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EventV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteSnippet: { + v1TrackEvent: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EventTrackV1"]; + }; + }; responses: { - /** @description Snippet deleted */ - 200: { + /** @description Event recorded */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; + "application/json": components["schemas"]["EventV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no contact with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateSnippet: { + v1ListEventNames: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateSnippet"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated snippet */ + /** @description Event names */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Unauthorized — missing or invalid auth */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EventNamesV1"]; }; - }; - /** @description Resource not found */ - 404: { + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description A snippet with that name already exists in this project */ - 409: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhooks: { + v1GetEventStats: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook list */ + /** @description Event counts */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookListResponse"]; + "application/json": components["schemas"]["EventStatsV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createWebhook: { + v1ListLists: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook created */ - 201: { + /** @description Subscriber lists */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCreateResponse"]; + "application/json": components["schemas"]["ListV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getWebhook: { + v1CreateList: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Webhook */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Create"]; }; - /** @description Validation error */ - 400: { + }; + responses: { + /** @description The created list */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteWebhook: { + v1GetList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook deleted */ + /** @description The list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateWebhook: { + v1DeleteList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook updated */ + /** @description List deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookGetResponse"]; + "application/json": components["schemas"]["ListV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateWebhookSecret: { + v1UpdateList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Update"]; + }; + }; responses: { - /** @description Secret rotated */ + /** @description The updated list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookRotateSecretResponse"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhookCalls: { + v1StartListValidationRun: { parameters: { - query?: { - limit?: number; - cursor?: string; - }; + query?: never; header?: never; path: { + /** @description The list to validate. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook call history */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookCallsListResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The run, accepted and queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListWebhooks: { + v1GetProject: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook list */ + /** @description The authenticated project */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1List"]; + "application/json": components["schemas"]["ProjectV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -12926,6 +12930,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — the project was deleted between authentication and this read. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -12955,26 +12968,26 @@ interface operations { }; }; }; - v1CreateWebhook: { + v1ListSegments: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The created webhook and its one-time signing secret */ - 201: { + /** @description Segment list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1Created"]; + "application/json": components["schemas"]["SegmentV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13024,29 +13037,30 @@ interface operations { }; }; }; - v1GetWebhook: { + v1CreateSegment: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Create"]; + }; + }; responses: { - /** @description The webhook */ - 200: { + /** @description Segment created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + 400: { headers: { [name: string]: unknown; }; @@ -13054,8 +13068,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; @@ -13063,8 +13077,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -13101,7 +13115,7 @@ interface operations { }; }; }; - v1DeleteWebhook: { + v1GetSegment: { parameters: { query?: never; header?: never; @@ -13113,13 +13127,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Webhook deleted */ + /** @description The segment */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1Deleted"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13140,7 +13154,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13178,7 +13192,7 @@ interface operations { }; }; }; - v1UpdateWebhook: { + v1DeleteSegment: { parameters: { query?: never; header?: never; @@ -13188,19 +13202,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated webhook */ + /** @description Segment deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1"]; + "application/json": components["schemas"]["SegmentV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13221,7 +13231,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13230,6 +13240,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `conflict` — the segment is still used by one or more active campaigns. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -13259,7 +13278,7 @@ interface operations { }; }; }; - v1RotateWebhookSecret: { + v1UpdateSegment: { parameters: { query?: never; header?: never; @@ -13269,15 +13288,19 @@ interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Update"]; + }; + }; responses: { - /** @description The new signing secret and the moment the previous one stops verifying */ + /** @description The updated segment */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1SecretRotated"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13298,7 +13321,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13336,639 +13359,609 @@ interface operations { }; }; }; - listApiKeys: { + v1ListSegmentContacts: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path: { - /** @description Project id. */ + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key list */ + /** @description Segment member list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiKeyListResponse"]; + "application/json": components["schemas"]["SegmentContactV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createApiKey: { + v1ListSuppressions: { parameters: { - query?: never; - header?: never; - path: { - /** @description Project id. */ - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateApiKeyBody"]; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Filter to one reason. Omit for every suppressed address. */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { - /** @description API key created; the secret is behind the reveal link. */ - 201: { + /** @description Suppression list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - /** @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. */ - data: components["schemas"]["ApiKey"] & { - /** - * Format: uri - * @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. - */ - revealUrl: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - }; - }; + "application/json": components["schemas"]["SuppressionV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateApiKey: { + v1CreateSuppression: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - /** @description API key id. */ - keyId: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description API key rotated; the new secret is behind the reveal link. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - lastFour: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - }; - }; - }; + requestBody: { + content: { + "application/json": components["schemas"]["SuppressionV1Create"]; }; - /** @description Validation error */ - 400: { + }; + responses: { + /** @description The suppressed address */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - revokeApiKey: { + v1GetSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ - id: string; - /** @description API key id. */ - keyId: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key revoked */ + /** @description The suppression record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listSuppressions: { + v1DeleteSuppression: { parameters: { - query?: { - limit?: number; - cursor?: string; - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description The suppressed address, URL-encoded. */ + email: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression list */ + /** @description Address removed from the suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionListResponse"]; + "application/json": components["schemas"]["SuppressionV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - addSuppression: { + v1ListTemplates: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the name. */ + search?: string; + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AddSuppression"]; - }; - }; + requestBody?: never; responses: { - /** @description Suppression added */ - 201: { + /** @description Template list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Suppression"]; + "application/json": components["schemas"]["TemplateV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - checkSuppression: { + v1CreateTemplate: { parameters: { query?: never; header?: never; - path: { - /** @description URL-encoded email address */ - email: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateV1Create"]; + }; + }; responses: { - /** @description Suppression check result */ - 200: { + /** @description The created template */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionCheckResponse"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - removeSuppression: { + v1GetTemplate: { parameters: { query?: never; header?: never; path: { - /** @description URL-encoded email address */ - email: string; + /** @description Resource id. */ + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression removed */ - 204: { + /** @description The template */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["TemplateV1"]; + }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListSuppressions: { + v1DeleteTemplate: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Filter to one reason. Omit for every suppressed address. */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression list */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1List"]; + "application/json": components["schemas"]["TemplateV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13989,6 +13982,24 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14018,26 +14029,29 @@ interface operations { }; }; }; - v1CreateSuppression: { + v1UpdateTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SuppressionV1Create"]; + "application/json": components["schemas"]["TemplateV1Update"]; }; }; responses: { - /** @description The suppressed address */ - 201: { + /** @description The updated template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14058,6 +14072,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14087,25 +14110,27 @@ interface operations { }; }; }; - v1GetSuppression: { + v1ListTopics: { parameters: { - query?: never; - header?: never; - path: { - /** @description The suppressed address, URL-encoded. */ - email: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + include_archived?: boolean | null; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The suppression record */ + /** @description One page of topics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1"]; + "application/json": components["schemas"]["TopicListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14126,15 +14151,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14164,25 +14180,26 @@ interface operations { }; }; }; - v1DeleteSuppression: { + v1CreateTopic: { parameters: { query?: never; header?: never; - path: { - /** @description The suppressed address, URL-encoded. */ - email: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicCreateV1"]; + }; + }; responses: { - /** @description Address removed from the suppression list */ - 200: { + /** @description The created topic */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1Deleted"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14232,97 +14249,97 @@ interface operations { }; }; }; - trackEvent: { + v1GetTopic: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TrackEvent"]; + path: { + /** @description The topic. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Event tracked */ + /** @description The topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TrackEventResponse"]; + "application/json": components["schemas"]["TopicV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListEvents: { + v1UpdateTopic: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only events with this exact name. */ - event_name?: string; + query?: never; + header?: never; + path: { + /** @description The topic. */ + id: string; }; - header?: never; - path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicUpdateV1"]; + }; + }; responses: { - /** @description Event list */ + /** @description The updated topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1List"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14372,26 +14389,29 @@ interface operations { }; }; }; - v1TrackEvent: { + v1SetTopicSubscription: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["EventTrackV1"]; + "application/json": components["schemas"]["TopicSubscribeV1"]; }; }; responses: { - /** @description Event recorded */ - 201: { + /** @description The resulting subscription */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1"]; + "application/json": components["schemas"]["TopicSubscriptionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14412,15 +14432,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no contact with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14450,7 +14461,7 @@ interface operations { }; }; }; - v1ListEventNames: { + v1GetUsage: { parameters: { query?: never; header?: never; @@ -14459,13 +14470,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Event names */ + /** @description Current usage */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventNamesV1"]; + "application/json": components["schemas"]["UsageV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14515,27 +14526,25 @@ interface operations { }; }; }; - v1GetEventStats: { + v1GetValidationRun: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description The validation run. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Event counts */ + /** @description The run */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStatsV1"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14585,27 +14594,31 @@ interface operations { }; }; }; - v1GetAnalyticsTimeseries: { + v1ListValidationRunResults: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ + verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; }; header?: never; - path?: never; + path: { + /** @description The validation run. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Daily time series */ + /** @description One page of results */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; + "application/json": components["schemas"]["EmailValidationResultListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14655,13 +14668,12 @@ interface operations { }; }; }; - v1GetCampaignAnalytics: { + v1ListWebhooks: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; header?: never; path?: never; @@ -14669,13 +14681,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description Webhook list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; + "application/json": components["schemas"]["WebhookV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14725,28 +14737,26 @@ interface operations { }; }; }; - v1ListTopCampaigns: { + v1CreateWebhook: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - limit?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Create"]; + }; + }; responses: { - /** @description Ranked campaigns */ - 200: { + /** @description The created webhook and its one-time signing secret */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; + "application/json": components["schemas"]["WebhookV1Created"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14796,29 +14806,25 @@ interface operations { }; }; }; - v1DiagnoseDeliverability: { + v1GetWebhook: { parameters: { - query: { - /** @description A sending domain in this project, e.g. `example.com`. */ - domain: 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. */ - address?: string; - /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ - window_days?: number; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description The diagnosis, with findings */ + /** @description The webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14839,6 +14845,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14868,30 +14883,25 @@ interface operations { }; }; }; - v1ListRecipientDomainStats: { + v1DeleteWebhook: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ - days?: number; - /** @description Restrict to one recipient domain. */ - domain?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Cursor-paginated recipient-domain rollup */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecipientDomainStatsV1List"]; + "application/json": components["schemas"]["WebhookV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14912,6 +14922,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14941,30 +14960,29 @@ interface operations { }; }; }; - v1ListDmarcReports: { + v1UpdateWebhook: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ - days?: number; - /** @description Restrict to reports about one of your domains. */ - domain?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Update"]; + }; + }; responses: { - /** @description Cursor-paginated DMARC aggregate reports */ + /** @description The updated webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DmarcReportV1List"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14985,6 +15003,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15014,22 +15041,25 @@ interface operations { }; }; }; - v1GetUsage: { + v1RotateWebhookSecret: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Current usage */ + /** @description The new signing secret and the moment the previous one stops verifying */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UsageV1"]; + "application/json": components["schemas"]["WebhookV1SecretRotated"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -15050,6 +15080,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15079,35 +15118,30 @@ interface operations { }; }; }; - v1GetProject: { + v1ListWorkflows: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The authenticated project */ + /** @description Workflow list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["WorkflowV1List"]; }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; @@ -15115,8 +15149,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — the project was deleted between authentication and this read. */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -15153,962 +15187,845 @@ interface operations { }; }; }; - listMailboxes: { + v1CreateWorkflow: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowCreateV1"]; + }; + }; responses: { - /** @description Mailbox list */ - 200: { + /** @description Workflow created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Mailbox"][]; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createMailbox: { + v1CancelWorkflowExecution: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateMailboxBody"]; + path: { + /** @description Workflow execution id. */ + execution_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Mailbox provisioned */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Mailbox"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description Cancelled execution */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowExecutionV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no execution with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getMailbox: { + v1GetWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox with connection settings */ + /** @description Workflow */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["MailboxDetail"]; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteMailbox: { + v1DeleteWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox deleted */ + /** @description Workflow deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - deleted: true; - }; - }; + "application/json": components["schemas"]["WorkflowDeletedV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the workflow still has running executions. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - draftMailboxMessage: { + v1UpdateWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["DraftMailboxMessage"]; + "application/json": components["schemas"]["WorkflowUpdateV1"]; }; }; responses: { - /** @description A draft. Nothing was sent. */ + /** @description Updated workflow */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @description Suggested subject, or null. */ - subject: string | null; - /** @description Suggested plain-text body, or null. */ - body: string | null; - /** @description Alternative subject lines (`subject` mode); empty otherwise. */ - subjects: string[]; - /** - * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. - * @enum {boolean} - */ - sent: false; - }; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; - }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the trigger cannot be changed while executions are running. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The drafting model was unreachable or returned nothing usable. */ - 502: { + /** @description `internal_error`. */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - sendMailboxMessage: { + v1CloneWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["ComposeMailboxMessage"]; + "application/json": components["schemas"]["WorkflowCloneV1"]; }; }; responses: { - /** @description Message submitted */ + /** @description The cloned workflow */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - submitted: true; - /** - * Format: uuid - * @description The conversation this send started. Replies thread onto it. - */ - conversationId: string; - /** - * Format: uuid - * @description The stored outbound message. - */ - messageId: string; - }; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description The mail server refused the submission. Nothing was sent. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listAppPasswords: { + v1ListWorkflowExecutions: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only executions in this state. */ + status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + }; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description App password list */ + /** @description Execution list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["AppPassword"][]; - }; + "application/json": components["schemas"]["WorkflowExecutionV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createAppPassword: { + v1StartWorkflowExecution: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateAppPassword"]; + "application/json": components["schemas"]["WorkflowExecutionStartV1"]; }; }; responses: { - /** @description App password created; the secret is behind the one-time link */ + /** @description Execution started */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["AppPasswordReveal"]; - }; + "application/json": components["schemas"]["WorkflowExecutionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - revokeAppPassword: { + v1GetWorkflowGraph: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; - passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description App password revoked */ + /** @description The workflow's graph */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - revoked: true; - }; - }; + "application/json": components["schemas"]["WorkflowGraphV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createProject: { + v1ReplaceWorkflowGraph: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Workflow id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": { - name: string; - /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. - * @enum {string} - */ - sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; + "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; }; }; responses: { - /** @description Project created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProjectRecord"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The graph as it now stands */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowGraphV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - }; - verifyEmailAddress: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["VerifyEmail"]; - }; - }; - responses: { - /** @description Verification result */ - 200: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VerifyEmailResponse"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation error */ - 400: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListTopics: { + v1PauseWorkflow: { parameters: { - query?: { - cursor?: string; - limit?: number; - include_archived?: boolean | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Workflow id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description One page of topics */ + /** @description The workflow, and the number of runs this call cancelled */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicListV1"]; + "application/json": components["schemas"]["WorkflowStateChangeV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16129,6 +16046,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16158,26 +16084,25 @@ interface operations { }; }; }; - v1CreateTopic: { + v1ResumeWorkflow: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TopicCreateV1"]; + path: { + /** @description Workflow id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The created topic */ - 201: { + /** @description The workflow, with `cancelled_executions` always 0 */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["WorkflowStateChangeV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16198,6 +16123,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16227,25 +16161,28 @@ interface operations { }; }; }; - v1GetTopic: { + v1GetWorkflowStats: { parameters: { - query?: never; + query?: { + /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ + from?: string | null; + }; header?: never; path: { - /** @description The topic. */ + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The topic */ + /** @description Workflow statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["WorkflowStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16266,6 +16203,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16295,492 +16241,565 @@ interface operations { }; }; }; - v1UpdateTopic: { + verifyEmailAddress: { parameters: { query?: never; header?: never; - path: { - /** @description The topic. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["TopicUpdateV1"]; + "application/json": components["schemas"]["VerifyEmail"]; }; }; responses: { - /** @description The updated topic */ + /** @description Verification result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["VerifyEmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + }; + }; + listWebhooks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["WebhookListResponse"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SetTopicSubscription: { + createWebhook: { parameters: { - query?: never; - header?: never; - path: { - /** @description The topic. */ - id: string; - }; + query?: never; + header?: never; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["TopicSubscribeV1"]; + "application/json": components["schemas"]["CreateWebhook"]; }; }; responses: { - /** @description The resulting subscription */ - 200: { + /** @description Webhook created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicSubscriptionV1"]; + "application/json": components["schemas"]["WebhookCreateResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetContactTopicPreferences: { + getWebhook: { parameters: { query?: never; header?: never; path: { - /** @description The contact. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The contact's preferences */ + /** @description Webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactTopicPreferencesV1"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ValidateEmails: { + deleteWebhook: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description One verdict per address, in the order they were given */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationBatchV1"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1StartListValidationRun: { + updateWebhook: { parameters: { query?: never; header?: never; path: { - /** @description The list to validate. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebhook"]; + }; + }; responses: { - /** @description The run, accepted and queued */ - 202: { + /** @description Webhook updated */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationRunV1"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetValidationRun: { + listWebhookCalls: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + }; header?: never; path: { - /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The run */ + /** @description Webhook call history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationRunV1"]; + "application/json": components["schemas"]["WebhookCallsListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListValidationRunResults: { + rotateWebhookSecret: { parameters: { - query?: { - cursor?: string; - limit?: number; - /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ - verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; - }; + query?: never; header?: never; path: { - /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description One page of results */ + /** @description Secret rotated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationResultListV1"]; + "application/json": components["schemas"]["WebhookRotateSecretResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; @@ -17929,16 +17948,18 @@ declare class TopicsResource { * is no delete — archiving is the retire button, because a topic is where * people's answers are recorded. {@link listAll} drives the loop for you. * - * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + * Paginated on `limit` + `after`, like every other v1 collection. */ list(query?: ListTopicsV1Query): Promise; /** * Iterate every topic across pages, yielding one topic at a time. * - * The walk is written out here rather than delegated to `paginateCursor` - * because this endpoint names its cursor `cursor` on both sides — the query - * parameter and the response field — where every other v1 list takes `after` - * and answers `next_cursor`. + * This used to be written out by hand: the endpoint named its cursor `cursor` + * on both sides where every other v1 list takes `after` and answers + * `next_cursor`, so the shared walker sent a parameter the route ignored and + * read a field it never returned — which silently re-fetched page one until + * `has_more` happened to be false. The route speaks the one dialect now, so + * this delegates like every other collection. */ listAll(query?: ListTopicsV1Query): AsyncGenerator; /** @@ -18038,19 +18059,17 @@ declare class ValidationResource { * 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. * - * This list pages on `cursor`, not the `after` every other v1 collection - * takes, and its envelope carries the next page under `cursor` rather than - * `next_cursor`. {@link listResultsAll} drives that loop for you. + * Pages on `after` and answers `next_cursor`, like every other v1 + * collection. {@link listResultsAll} drives that loop for you. */ listResults(id: string, query?: ListValidationResultsV1Query): Promise; /** * Iterate every result across pages, yielding one address's verdict at a time. * - * Hand-rolled rather than routed through `paginateCursor`: the shared helper - * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on - * both sides, so the helper would send an ignored parameter and re-fetch page - * one forever. Stops on `has_more: false`, a null cursor, or a cursor the - * server repeats. + * This was hand-rolled through 1.0, because the endpoint spoke `cursor` on + * both sides while the shared helper 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. */ listResultsAll(id: string, query?: ListValidationResultsV1Query): AsyncGenerator; } diff --git a/dist/index.d.ts b/dist/index.d.ts index 7e54f05..a030b19 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ interface paths { - "/api/v1/campaigns": { + "/api/contacts": { parameters: { query?: never; header?: never; @@ -11,69 +11,55 @@ interface paths { cookie?: never; }; /** - * List campaigns - * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * List contacts + * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListCampaigns"]; + get: operations["listContacts"]; put?: never; /** - * Create a campaign - * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. - * - * The `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. - * - * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * Create a contact + * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateCampaign"]; + post: operations["createContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}": { + "/api/contacts/bulk": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve a campaign - * @description Fetch one campaign, including its materialized delivery counters. + * Bulk-create contacts + * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetCampaign"]; - put?: never; - post?: never; + post: operations["bulkCreateContacts"]; /** - * Delete a campaign - * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. + * Bulk-delete contacts + * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteCampaign"]; + delete: operations["bulkDeleteContacts"]; options?: never; head?: never; - /** - * Update a campaign - * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. - * - * Changing `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. - * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. - */ - patch: operations["v1UpdateCampaign"]; + patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/send": { + "/api/contacts/upsert": { parameters: { query?: never; header?: never; @@ -83,69 +69,115 @@ interface paths { get?: never; put?: never; /** - * Send or schedule a campaign - * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. - * - * **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. - * - * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * Create or update a contact by email + * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. * - * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1SendCampaign"]; + post: operations["upsertContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/cancel": { + "/api/contacts/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a contact + * @description Requires the `contacts:read` scope — View your contacts and their custom fields. + */ + get: operations["getContact"]; put?: never; + post?: never; /** - * Cancel a campaign - * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. + * Delete a contact + * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). * - * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + delete: operations["deleteContact"]; + options?: never; + head?: never; + /** + * Update a contact + * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CancelCampaign"]; + patch: operations["updateContact"]; + trace?: never; + }; + "/api/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List sending domains + * @description List all domains for the authenticated project. + * + * Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["listDomains"]; + put?: never; + /** + * Add a sending domain + * @description Register a new domain with SES and persist its DKIM tokens. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["addDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/pause": { + "/api/domains/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a sending domain + * @description Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["getDomain"]; put?: never; + post?: never; /** - * Pause a sending campaign - * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. + * Remove a sending domain + * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1PauseCampaign"]; - delete?: never; + delete: operations["deleteDomain"]; options?: never; head?: never; - patch?: never; + /** + * Assign a sending identity to a stream + * @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. + * + * Streams 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. + * + * At 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + patch: operations["assignDomainStream"]; trace?: never; }; - "/api/v1/campaigns/{id}/resume": { + "/api/domains/{id}/dodomain-session": { parameters: { query?: never; header?: never; @@ -155,19 +187,23 @@ interface paths { get?: never; put?: never; /** - * Resume a paused campaign - * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. + * Start guided DNS setup + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * The 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. + * + * `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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1ResumeCampaign"]; + post: operations["startDomainSetup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/stats": { + "/api/domains/{id}/verify": { parameters: { query?: never; header?: never; @@ -175,23 +211,27 @@ interface paths { cookie?: never; }; /** - * Retrieve campaign statistics - * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. - * - * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. + * Read SES verification status + * @description Read the current SES verification status without forcing a refresh. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1GetCampaignStats"]; + get: operations["getDomainVerification"]; put?: never; - post?: never; + /** + * Trigger SES verification + * @description Force a refresh of the domain's SES verification status. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["verifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/failures": { + "/api/emails": { parameters: { query?: never; header?: never; @@ -199,25 +239,27 @@ interface paths { cookie?: never; }; /** - * List a campaign's failed sends - * @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. - * - * `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. - * - * Cursor-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. + * List emails + * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["v1ListCampaignFailures"]; + get: operations["listEmails"]; put?: never; - post?: never; + /** + * Send a single transactional email + * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * + * Requires the `emails:send` scope — Send emails from your verified domains. + */ + post: operations["sendEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/retry-failed": { + "/api/emails/batch": { parameters: { query?: never; header?: never; @@ -227,23 +269,21 @@ interface paths { get?: never; put?: never; /** - * Retry a campaign's failed sends - * @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. - * - * The 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. + * Send a batch of emails + * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. * - * Only 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. + * The 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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `emails:send` scope — Send emails from your verified domains. */ - post: operations["v1RetryCampaignFailures"]; + post: operations["sendEmailBatch"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments": { + "/api/emails/{id}": { parameters: { query?: never; header?: never; @@ -251,91 +291,91 @@ interface paths { cookie?: never; }; /** - * List segments - * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * Get a single email + * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. * - * `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. + * `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. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["v1ListSegments"]; + get: operations["getEmail"]; put?: never; - /** - * Create a segment - * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. - * - * A `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. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. - */ - post: operations["v1CreateSegment"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments/{id}": { + "/api/emails/{id}/schedule": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Retrieve a segment - * @description Fetch one segment, including its saved `condition` and materialized `member_count`. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. - */ - get: operations["v1GetSegment"]; + get?: never; put?: never; post?: never; /** - * Delete a segment - * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * Cancel a scheduled (still-PENDING) email + * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. */ - delete: operations["v1DeleteSegment"]; + delete: operations["cancelScheduledEmail"]; options?: never; head?: never; + patch?: never; + trace?: never; + }; + "/api/lists/{id}/subscribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; /** - * Update a segment - * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * Subscribe a contact to a list + * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. * - * `type` is not accepted here — see the create operation. + * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * **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. + * + * `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. */ - patch: operations["v1UpdateSegment"]; + post: operations["subscribeToList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/v1/segments/{id}/contacts": { + "/api/lists/{id}/unsubscribe": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List the contacts in a segment - * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. - * - * Cursors 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. + * Unsubscribe a contact from a list + * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. */ - get: operations["v1ListSegmentContacts"]; - put?: never; - post?: never; + post: operations["unsubscribeFromList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows": { + "/api/mailboxes": { parameters: { query?: never; header?: never; @@ -343,31 +383,39 @@ interface paths { cookie?: never; }; /** - * List workflows - * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * List mailboxes + * @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. * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * This 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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1ListWorkflows"]; + get: operations["listMailboxes"]; put?: never; /** - * Create a workflow - * @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`. + * Create a mailbox + * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. * - * Pass `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. + * Three consequences worth knowing before you call it: * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * - **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. + * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. + * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * + * Retrying 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. + * + * `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. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1CreateWorkflow"]; + post: operations["createMailbox"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}": { + "/api/mailboxes/{id}": { parameters: { query?: never; header?: never; @@ -375,37 +423,29 @@ interface paths { cookie?: never; }; /** - * Retrieve a workflow - * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. + * Get a mailbox + * @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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1GetWorkflow"]; + get: operations["getMailbox"]; put?: never; post?: never; /** - * Delete a workflow - * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. + * Delete a mailbox + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires an admin of the project. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - delete: operations["v1DeleteWorkflow"]; + delete: operations["deleteMailbox"]; options?: never; head?: never; - /** - * Update a workflow - * @description Sparse update — omitted fields are left unchanged. - * - * Two 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. - * - * `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. - * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. - */ - patch: operations["v1UpdateWorkflow"]; + patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/executions": { + "/api/mailboxes/{id}/app-passwords": { parameters: { query?: never; header?: never; @@ -413,29 +453,33 @@ interface paths { cookie?: never; }; /** - * List a workflow's executions - * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. + * List a mailbox's app passwords + * @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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1ListWorkflowExecutions"]; + get: operations["listAppPasswords"]; put?: never; /** - * Start a workflow for a contact - * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. + * Create an app password + * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. * - * 409 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. + * **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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * That 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. + * + * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1StartWorkflowExecution"]; + post: operations["createAppPassword"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/executions/{execution_id}/cancel": { + "/api/mailboxes/{id}/app-passwords/{passwordId}": { parameters: { query?: never; header?: never; @@ -444,106 +488,119 @@ interface paths { }; get?: never; put?: never; + post?: never; /** - * Cancel a workflow execution - * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. + * Revoke an app password + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires an admin of the project. An API key is refused with 401. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1CancelWorkflowExecution"]; - delete?: never; + delete: operations["revokeAppPassword"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/stats": { + "/api/mailboxes/{id}/drafts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve workflow statistics - * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. + * Draft a message with AI + * @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. * - * The 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. + * **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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * That 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. + * + * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * + * Drafting is capped at 120 requests per hour per project. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1GetWorkflowStats"]; - put?: never; - post?: never; + post: operations["draftMailboxMessage"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/graph": { + "/api/mailboxes/{id}/messages": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve a workflow's step graph - * @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. + * Send a message from a mailbox + * @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. * - * A 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. + * **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. * - * The 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. + * **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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. - */ - get: operations["v1GetWorkflowGraph"]; - /** - * Replace a workflow's step graph - * @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. + * Bcc 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. * - * A 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. + * Refusals worth handling by name: * - * Refused 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. + * - `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. + * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. + * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. + * - `429` — a mailbox may send 60 messages an hour through this endpoint. * - * The 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. + * The message is stored as a new conversation on the mailbox, so the reply threads onto it. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. */ - put: operations["v1ReplaceWorkflowGraph"]; - post?: never; + post: operations["sendMailboxMessage"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/clone": { + "/api/projects/{id}/api-keys": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List API keys for a project + * @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. + * + * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + */ + get: operations["listApiKeys"]; put?: never; /** - * Clone a workflow - * @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. + * Create an API key + * @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. * - * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. + * **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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1CloneWorkflow"]; + post: operations["createApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/pause": { + "/api/projects/{id}/api-keys/{keyId}": { parameters: { query?: never; header?: never; @@ -552,24 +609,20 @@ interface paths { }; get?: never; put?: never; + post?: never; /** - * Pause a workflow and cancel its running executions - * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. - * - * `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. - * - * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. + * Revoke an API key + * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1PauseWorkflow"]; - delete?: never; + delete: operations["revokeApiKey"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/resume": { + "/api/projects/{id}/api-keys/{keyId}/rotate": { parameters: { query?: never; header?: never; @@ -579,79 +632,77 @@ interface paths { get?: never; put?: never; /** - * Resume a paused workflow - * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. - * - * Refused 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. + * Rotate an API key's secret + * @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`). * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1ResumeWorkflow"]; + post: operations["rotateApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails": { + "/api/snippets": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a transactional email - * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. - * - * This 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. - * - * Exactly 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. - * - * `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. + * List snippets + * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. * - * An 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. + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listSnippets"]; + put?: never; + /** + * Create a snippet + * @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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1SendEmail"]; + post: operations["createSnippet"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails/test": { + "/api/snippets/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a snippet + * @description Requires the `templates:read` scope — View your email templates. + */ + get: operations["getSnippet"]; put?: never; + post?: never; /** - * Send a sandbox test email - * @description Prove that sending works — before any domain, DNS record or verification exists. - * - * The 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. - * - * That 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. - * - * Sandbox 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. + * Delete a snippet + * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. * - * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1SendTestEmail"]; - delete?: never; + delete: operations["deleteSnippet"]; options?: never; head?: never; - patch?: never; + /** + * Update a snippet + * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateSnippet"]; trace?: never; }; - "/api/emails": { + "/api/suppression": { parameters: { query?: never; header?: never; @@ -659,27 +710,27 @@ interface paths { cookie?: never; }; /** - * List emails - * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. + * List suppressed emails + * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `suppression:read` scope — View the addresses on your suppression list. */ - get: operations["listEmails"]; + get: operations["listSuppressions"]; put?: never; /** - * Send a single transactional email - * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * Manually add an email to the suppression list + * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. */ - post: operations["sendEmail"]; + post: operations["addSuppression"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}": { + "/api/suppression/{email}": { parameters: { query?: never; header?: never; @@ -687,95 +738,109 @@ interface paths { cookie?: never; }; /** - * Get a single email - * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. - * - * `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. + * Check whether an email is suppressed + * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `suppression:read` scope — View the addresses on your suppression list. */ - get: operations["getEmail"]; + get: operations["checkSuppression"]; put?: never; post?: never; - delete?: never; + /** + * Remove an email from the suppression list + * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["removeSuppression"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/batch": { + "/api/templates": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a batch of emails - * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. + * List templates + * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. * - * The 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. + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listTemplates"]; + put?: never; + /** + * Create a template + * @description Create a new email template. The `from` domain must already be verified for the project. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["sendEmailBatch"]; + post: operations["createTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}/schedule": { + "/api/templates/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a template + * @description Requires the `templates:read` scope — View your email templates. + */ + get: operations["getTemplate"]; put?: never; post?: never; /** - * Cancel a scheduled (still-PENDING) email - * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. + * Delete a template + * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["cancelScheduledEmail"]; + delete: operations["deleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Update one or more fields. If `from` changes, the new domain must already be verified. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateTemplate"]; trace?: never; }; - "/api/contacts": { + "/api/track": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List contacts - * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. - * - * Requires the `contacts:read` scope — View your contacts and their custom fields. - */ - get: operations["listContacts"]; + get?: never; put?: never; /** - * Create a contact - * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. + * Track a custom event for a contact + * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `events:write` scope — Record custom events for your contacts. */ - post: operations["createContact"]; + post: operations["trackEvent"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/upsert": { + "/api/users/me/projects": { parameters: { query?: never; header?: never; @@ -785,47 +850,47 @@ interface paths { get?: never; put?: never; /** - * Create or update a contact by email - * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. + * Create a project + * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Preconditions 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. + * + * Requires the `projects:write` scope — Create new projects on your account. */ - post: operations["upsertContact"]; + post: operations["createProject"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/bulk": { + "/api/v1/analytics/campaigns": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Bulk-create contacts - * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. + * Retrieve campaign totals and engagement + * @description Campaign counts plus average open and click rates. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - post: operations["bulkCreateContacts"]; - /** - * Bulk-delete contacts - * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. + * `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. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - delete: operations["bulkDeleteContacts"]; + get: operations["v1GetCampaignAnalytics"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/{id}": { + "/api/v1/analytics/timeseries": { parameters: { query?: never; header?: never; @@ -833,31 +898,23 @@ interface paths { cookie?: never; }; /** - * Get a contact - * @description Requires the `contacts:read` scope — View your contacts and their custom fields. + * Retrieve the daily email time series + * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - get: operations["getContact"]; + get: operations["v1GetAnalyticsTimeseries"]; put?: never; post?: never; - /** - * Delete a contact - * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - delete: operations["deleteContact"]; + delete?: never; options?: never; head?: never; - /** - * Update a contact - * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - patch: operations["updateContact"]; + patch?: never; trace?: never; }; - "/api/v1/contacts": { + "/api/v1/analytics/top-campaigns": { parameters: { query?: never; header?: never; @@ -865,29 +922,23 @@ interface paths { cookie?: never; }; /** - * List contacts - * @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. + * List the best-performing campaigns + * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. * - * A 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. + * `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. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - get: operations["v1ListContacts"]; + get: operations["v1ListTopCampaigns"]; put?: never; - /** - * Create a contact - * @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. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - post: operations["v1CreateContact"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/contacts/{id}": { + "/api/v1/campaigns": { parameters: { query?: never; header?: never; @@ -895,63 +946,69 @@ interface paths { cookie?: never; }; /** - * Retrieve a contact - * @description Fetch one contact by id. + * List campaigns + * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - get: operations["v1GetContact"]; + get: operations["v1ListCampaigns"]; put?: never; - post?: never; /** - * Delete a contact - * @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. + * Create a campaign + * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - delete: operations["v1DeleteContact"]; - options?: never; - head?: never; - /** - * Update a contact - * @description Partial update. Omitted fields are left alone. - * - * `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. + * The `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. * - * `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. + * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - patch: operations["v1UpdateContact"]; + post: operations["v1CreateCampaign"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/lists/{id}/subscribe": { + "/api/v1/campaigns/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Subscribe a contact to a list - * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. - * - * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. + * Retrieve a campaign + * @description Fetch one campaign, including its materialized delivery counters. * - * **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. + * Requires the `campaigns:read` scope — View your campaigns and their performance. + */ + get: operations["v1GetCampaign"]; + put?: never; + post?: never; + /** + * Delete a campaign + * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. * - * `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. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["subscribeToList"]; - delete?: never; + delete: operations["v1DeleteCampaign"]; options?: never; head?: never; - patch?: never; + /** + * Update a campaign + * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * + * Changing `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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + */ + patch: operations["v1UpdateCampaign"]; trace?: never; }; - "/api/lists/{id}/unsubscribe": { + "/api/v1/campaigns/{id}/cancel": { parameters: { query?: never; header?: never; @@ -961,19 +1018,21 @@ interface paths { get?: never; put?: never; /** - * Unsubscribe a contact from a list - * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. + * Cancel a campaign + * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. * - * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. + * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["unsubscribeFromList"]; + post: operations["v1CancelCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/lists": { + "/api/v1/campaigns/{id}/failures": { parameters: { query?: never; header?: never; @@ -981,189 +1040,145 @@ interface paths { cookie?: never; }; /** - * List subscriber lists - * @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. - * - * `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. - * - * Requires the `lists:read` scope — View your subscriber lists and who is on them. - */ - get: operations["v1ListLists"]; - put?: never; - /** - * Create a subscriber list - * @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`. + * List a campaign's failed sends + * @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. * - * **`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. + * `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. * - * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. + * Cursor-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. * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["v1CreateList"]; + get: operations["v1ListCampaignFailures"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/lists/{id}": { + "/api/v1/campaigns/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Retrieve a subscriber list - * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. - * - * Requires the `lists:read` scope — View your subscriber lists and who is on them. - */ - get: operations["v1GetList"]; + get?: never; put?: never; - post?: never; /** - * Delete a subscriber list - * @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. + * Pause a sending campaign + * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["v1DeleteList"]; + post: operations["v1PauseCampaign"]; + delete?: never; options?: never; head?: never; - /** - * Update a subscriber list - * @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. - * - * **`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. - * - * Turning `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. - * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. - */ - patch: operations["v1UpdateList"]; + patch?: never; trace?: never; }; - "/api/domains": { + "/api/v1/campaigns/{id}/resume": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List sending domains - * @description List all domains for the authenticated project. - * - * Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["listDomains"]; + get?: never; put?: never; /** - * Add a sending domain - * @description Register a new domain with SES and persist its DKIM tokens. + * Resume a paused campaign + * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["addDomain"]; + post: operations["v1ResumeCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}": { + "/api/v1/campaigns/{id}/retry-failed": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a sending domain - * @description Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["getDomain"]; + get?: never; put?: never; - post?: never; - /** - * Remove a sending domain - * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. - * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - delete: operations["deleteDomain"]; - options?: never; - head?: never; /** - * Assign a sending identity to a stream - * @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. + * Retry a campaign's failed sends + * @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. * - * Streams 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. + * The 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. * - * At 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. + * Only 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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - patch: operations["assignDomainStream"]; + post: operations["v1RetryCampaignFailures"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/domains/{id}/verify": { + "/api/v1/campaigns/{id}/send": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Read SES verification status - * @description Read the current SES verification status without forcing a refresh. - * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - get: operations["getDomainVerification"]; + get?: never; put?: never; /** - * Trigger SES verification - * @description Force a refresh of the domain's SES verification status. + * Send or schedule a campaign + * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * **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. + * + * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * + * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. */ - post: operations["verifyDomain"]; + post: operations["v1SendCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/dodomain-session": { + "/api/v1/campaigns/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Start guided DNS setup - * @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. - * - * The 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. + * Retrieve campaign statistics + * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. * - * `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. + * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["startDomainSetup"]; + get: operations["v1GetCampaignStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/domains": { + "/api/v1/contacts": { parameters: { query?: never; header?: never; @@ -1171,35 +1186,29 @@ interface paths { cookie?: never; }; /** - * List sending domains - * @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. + * List contacts + * @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. * - * `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. + * A 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. * - * Requires the `domains:read` scope — View your sending domains and their verification status. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListDomains"]; + get: operations["v1ListContacts"]; put?: never; /** - * Add a sending domain - * @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. - * - * `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. - * - * `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. - * - * A 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. + * Create a contact + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateDomain"]; + post: operations["v1CreateContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/domains/{id}": { + "/api/v1/contacts/{id}": { parameters: { query?: never; header?: never; @@ -1207,55 +1216,63 @@ interface paths { cookie?: never; }; /** - * Retrieve a sending domain - * @description Fetch one sending domain by id. + * Retrieve a contact + * @description Fetch one contact by id. * - * Requires the `domains:read` scope — View your sending domains and their verification status. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1GetDomain"]; + get: operations["v1GetContact"]; put?: never; post?: never; /** - * Remove a sending domain - * @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. - * - * The 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. + * Delete a contact + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteDomain"]; + delete: operations["v1DeleteContact"]; options?: never; head?: never; - patch?: never; + /** + * Update a contact + * @description Partial update. Omitted fields are left alone. + * + * `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. + * + * `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. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + patch: operations["v1UpdateContact"]; trace?: never; }; - "/api/v1/domains/{id}/verify": { + "/api/v1/contacts/{id}/topics": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Refresh a sending domain's verification state - * @description Re-read this domain's state from SES and DNS and return the refreshed document. + * Get a contact's topic preferences + * @description Everything this contact has said they want, as the send path reads it. * - * This 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. + * `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. * - * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * The 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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - post: operations["v1VerifyDomain"]; + get: operations["v1GetContactTopicPreferences"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates": { + "/api/v1/deliverability/diagnose": { parameters: { query?: never; header?: never; @@ -1263,27 +1280,25 @@ interface paths { cookie?: never; }; /** - * List templates - * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. + * Diagnose why mail from a domain is not arriving + * @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. * - * Requires the `templates:read` scope — View your email templates. - */ - get: operations["listTemplates"]; - put?: never; - /** - * Create a template - * @description Create a new email template. The `from` domain must already be verified for the project. + * Everything 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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * `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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - post: operations["createTemplate"]; + get: operations["v1DiagnoseDeliverability"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates/{id}": { + "/api/v1/deliverability/dmarc": { parameters: { query?: never; header?: never; @@ -1291,31 +1306,27 @@ interface paths { cookie?: never; }; /** - * Get a template - * @description Requires the `templates:read` scope — View your email templates. + * DMARC aggregate reports for your domains + * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * + * The 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. + * + * `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. + * + * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - get: operations["getTemplate"]; + get: operations["v1ListDmarcReports"]; put?: never; post?: never; - /** - * Delete a template - * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - delete: operations["deleteTemplate"]; + delete?: never; options?: never; head?: never; - /** - * Update a template - * @description Update one or more fields. If `from` changes, the new domain must already be verified. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateTemplate"]; + patch?: never; trace?: never; }; - "/api/v1/templates": { + "/api/v1/deliverability/domains": { parameters: { query?: never; header?: never; @@ -1323,31 +1334,25 @@ interface paths { cookie?: never; }; /** - * List templates - * @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. + * Delivery outcomes per recipient domain + * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. * - * `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. + * This 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. * - * A 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. + * The 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. * - * Requires the `templates:read` scope — View your email templates. + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - get: operations["v1ListTemplates"]; + get: operations["v1ListRecipientDomainStats"]; put?: never; - /** - * Create a template - * @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. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - post: operations["v1CreateTemplate"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/templates/{id}": { + "/api/v1/domains": { parameters: { query?: never; header?: never; @@ -1355,37 +1360,35 @@ interface paths { cookie?: never; }; /** - * Retrieve a template - * @description Fetch one template by id. + * List sending domains + * @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. * - * Requires the `templates:read` scope — View your email templates. + * `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. + * + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetTemplate"]; + get: operations["v1ListDomains"]; put?: never; - post?: never; /** - * Delete a template - * @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. + * Add a sending domain + * @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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - delete: operations["v1DeleteTemplate"]; - options?: never; - head?: never; - /** - * Update a template - * @description Partial update. Omitted fields are left alone. + * `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. * - * Changing `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. + * `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. * - * A `from` supplied here is verified before anything is written, on the same terms as create. + * A 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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - patch: operations["v1UpdateTemplate"]; + post: operations["v1CreateDomain"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/snippets": { + "/api/v1/domains/{id}": { parameters: { query?: never; header?: never; @@ -1393,115 +1396,113 @@ interface paths { cookie?: never; }; /** - * List snippets - * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. + * Retrieve a sending domain + * @description Fetch one sending domain by id. * - * Requires the `templates:read` scope — View your email templates. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["listSnippets"]; + get: operations["v1GetDomain"]; put?: never; + post?: never; /** - * Create a snippet - * @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. + * Remove a sending domain + * @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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * The 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["createSnippet"]; - delete?: never; + delete: operations["v1DeleteDomain"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/snippets/{id}": { + "/api/v1/domains/{id}/verify": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a snippet - * @description Requires the `templates:read` scope — View your email templates. - */ - get: operations["getSnippet"]; + get?: never; put?: never; - post?: never; /** - * Delete a snippet - * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. + * Refresh a sending domain's verification state + * @description Re-read this domain's state from SES and DNS and return the refreshed document. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * This 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. + * + * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - delete: operations["deleteSnippet"]; + post: operations["v1VerifyDomain"]; + delete?: never; options?: never; head?: never; - /** - * Update a snippet - * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateSnippet"]; + patch?: never; trace?: never; }; - "/api/webhooks": { + "/api/v1/email-validations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List user webhooks - * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). - * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. - */ - get: operations["listWebhooks"]; + get?: never; put?: never; /** - * Create a webhook - * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. + * Validate a batch of email addresses + * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * The 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. + * + * `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. + * + * The 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. */ - post: operations["createWebhook"]; + post: operations["v1ValidateEmails"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}": { + "/api/v1/emails": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a webhook - * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. - */ - get: operations["getWebhook"]; + get?: never; put?: never; - post?: never; /** - * Delete a webhook - * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * Send a transactional email + * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * This 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. + * + * Exactly 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. + * + * `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. + * + * An 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["deleteWebhook"]; + post: operations["v1SendEmail"]; + delete?: never; options?: never; head?: never; - /** - * Update a webhook - * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - patch: operations["updateWebhook"]; + patch?: never; trace?: never; }; - "/api/webhooks/{id}/rotate-secret": { + "/api/v1/emails/test": { parameters: { query?: never; header?: never; @@ -1511,19 +1512,25 @@ interface paths { get?: never; put?: never; /** - * Rotate the webhook signing secret - * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * Send a sandbox test email + * @description Prove that sending works — before any domain, DNS record or verification exists. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * The 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. + * + * That 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. + * + * Sandbox 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. + * + * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. */ - post: operations["rotateWebhookSecret"]; + post: operations["v1SendTestEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}/calls": { + "/api/v1/events": { parameters: { query?: never; header?: never; @@ -1531,21 +1538,37 @@ interface paths { cookie?: never; }; /** - * List recent webhook calls - * @description Cursor-paginated list of recent delivery attempts for a single webhook. + * List events + * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * A 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["listWebhookCalls"]; + get: operations["v1ListEvents"]; put?: never; - post?: never; + /** + * Record an event + * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. + * + * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * + * Reserved 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. + * + * This 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. + * + * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["v1TrackEvent"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/webhooks": { + "/api/v1/events/names": { parameters: { query?: never; header?: never; @@ -1553,31 +1576,21 @@ interface paths { cookie?: never; }; /** - * List webhooks - * @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. - * - * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. + * List event names + * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["v1ListWebhooks"]; + get: operations["v1ListEventNames"]; put?: never; - /** - * Create a webhook - * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. - * - * The 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. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - post: operations["v1CreateWebhook"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/webhooks/{id}": { + "/api/v1/events/stats": { parameters: { query?: never; header?: never; @@ -1585,63 +1598,57 @@ interface paths { cookie?: never; }; /** - * Retrieve a webhook - * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. + * Retrieve event counts + * @description Per-name event counts over a bounded window, most frequent first. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * The 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["v1GetWebhook"]; + get: operations["v1GetEventStats"]; put?: never; post?: never; - /** - * Delete a webhook - * @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. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - delete: operations["v1DeleteWebhook"]; + delete?: never; options?: never; head?: never; - /** - * Update a webhook - * @description Partial update. 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 part of this response. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - patch: operations["v1UpdateWebhook"]; + patch?: never; trace?: never; }; - "/api/v1/webhooks/{id}/rotate-secret": { + "/api/v1/lists": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List subscriber lists + * @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. + * + * `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. + * + * Requires the `lists:read` scope — View your subscriber lists and who is on them. + */ + get: operations["v1ListLists"]; put?: never; /** - * Rotate a webhook signing secret - * @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. + * Create a subscriber list + * @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`. * - * Rotation 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. + * **`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. * - * `url`, `event_types` and `status` are unchanged. + * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["v1RotateWebhookSecret"]; + post: operations["v1CreateList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys": { + "/api/v1/lists/{id}": { parameters: { query?: never; header?: never; @@ -1649,29 +1656,37 @@ interface paths { cookie?: never; }; /** - * List API keys for a project - * @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. + * Retrieve a subscriber list + * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. * - * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - get: operations["listApiKeys"]; + get: operations["v1GetList"]; put?: never; + post?: never; /** - * Create an API key - * @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. - * - * **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. + * Delete a subscriber list + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["createApiKey"]; - delete?: never; + delete: operations["v1DeleteList"]; options?: never; head?: never; - patch?: never; + /** + * Update a subscriber list + * @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. + * + * **`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. + * + * Turning `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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + patch: operations["v1UpdateList"]; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}/rotate": { + "/api/v1/lists/{id}/validation-runs": { parameters: { query?: never; header?: never; @@ -1681,41 +1696,49 @@ interface paths { get?: never; put?: never; /** - * Rotate an API key's secret - * @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`). + * Validate every address on a list + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * This 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`. + * + * A 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. */ - post: operations["rotateApiKey"]; + post: operations["v1StartListValidationRun"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}": { + "/api/v1/projects": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Revoke an API key - * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * Retrieve the authenticated project + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * `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). + * + * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * + * Requires the `projects:read` scope — View your projects and their settings. */ - delete: operations["revokeApiKey"]; + get: operations["v1GetProject"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression": { + "/api/v1/segments": { parameters: { query?: never; header?: never; @@ -1723,27 +1746,31 @@ interface paths { cookie?: never; }; /** - * List suppressed emails - * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. + * List segments + * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * `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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["listSuppressions"]; + get: operations["v1ListSegments"]; put?: never; /** - * Manually add an email to the suppression list - * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * Create a segment + * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * A `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. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - post: operations["addSuppression"]; + post: operations["v1CreateSegment"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression/{email}": { + "/api/v1/segments/{id}": { parameters: { query?: never; header?: never; @@ -1751,27 +1778,35 @@ interface paths { cookie?: never; }; /** - * Check whether an email is suppressed - * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * Retrieve a segment + * @description Fetch one segment, including its saved `condition` and materialized `member_count`. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["checkSuppression"]; + get: operations["v1GetSegment"]; put?: never; post?: never; /** - * Remove an email from the suppression list - * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * Delete a segment + * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - delete: operations["removeSuppression"]; + delete: operations["v1DeleteSegment"]; options?: never; head?: never; - patch?: never; + /** + * Update a segment + * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * + * `type` is not accepted here — see the create operation. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + patch: operations["v1UpdateSegment"]; trace?: never; }; - "/api/v1/suppressions": { + "/api/v1/segments/{id}/contacts": { parameters: { query?: never; header?: never; @@ -1779,22 +1814,46 @@ interface paths { cookie?: never; }; /** - * List suppressed addresses - * @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. + * List the contacts in a segment + * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. * - * A 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. + * Cursors 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. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["v1ListSuppressions"]; + get: operations["v1ListSegmentContacts"]; put?: never; - /** - * Suppress an address - * @description Add an address to this project's suppression list, so no further send reaches it. - * - * Idempotent: 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. - * - * `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. + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List suppressed addresses + * @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. + * + * A 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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1ListSuppressions"]; + put?: never; + /** + * Suppress an address + * @description Add an address to this project's suppression list, so no further send reaches it. + * + * Idempotent: 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. + * + * `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. * * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. */ @@ -1841,29 +1900,39 @@ interface paths { patch?: never; trace?: never; }; - "/api/track": { + "/api/v1/templates": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List templates + * @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. + * + * `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. + * + * A 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. + * + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["v1ListTemplates"]; put?: never; /** - * Track a custom event for a contact - * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. + * Create a template + * @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. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["trackEvent"]; + post: operations["v1CreateTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events": { + "/api/v1/templates/{id}": { parameters: { query?: never; header?: never; @@ -1871,37 +1940,37 @@ interface paths { cookie?: never; }; /** - * List events - * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. - * - * A 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. + * Retrieve a template + * @description Fetch one template by id. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListEvents"]; + get: operations["v1GetTemplate"]; put?: never; + post?: never; /** - * Record an event - * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. - * - * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * Delete a template + * @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. * - * Reserved 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. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + delete: operations["v1DeleteTemplate"]; + options?: never; + head?: never; + /** + * Update a template + * @description Partial update. Omitted fields are left alone. * - * This 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. + * Changing `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. * - * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * A `from` supplied here is verified before anything is written, on the same terms as create. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1TrackEvent"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["v1UpdateTemplate"]; trace?: never; }; - "/api/v1/events/names": { + "/api/v1/topics": { parameters: { query?: never; header?: never; @@ -1909,45 +1978,33 @@ interface paths { cookie?: never; }; /** - * List event names - * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * List topics + * @description The subjects this project mails about, cursor-paginated and newest first. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Archived 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. + * + * `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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - get: operations["v1ListEventNames"]; + get: operations["v1ListTopics"]; put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/events/stats": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; /** - * Retrieve event counts - * @description Per-name event counts over a bounded window, most frequent first. + * Create a topic + * @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. * - * The 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. + * `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`. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. */ - get: operations["v1GetEventStats"]; - put?: never; - post?: never; + post: operations["v1CreateTopic"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/timeseries": { + "/api/v1/topics/{id}": { parameters: { query?: never; header?: never; @@ -1955,49 +2012,53 @@ interface paths { cookie?: never; }; /** - * Retrieve the daily email time series - * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. - * - * `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. - * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Retrieve a topic + * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - get: operations["v1GetAnalyticsTimeseries"]; + get: operations["v1GetTopic"]; put?: never; post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** + * Update a topic + * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + patch: operations["v1UpdateTopic"]; trace?: never; }; - "/api/v1/analytics/campaigns": { + "/api/v1/topics/{id}/subscriptions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve campaign totals and engagement - * @description Campaign counts plus average open and click rates. + * Subscribe or unsubscribe a contact from a topic + * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. * - * `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. + * `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. * - * `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. + * `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. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. */ - get: operations["v1GetCampaignAnalytics"]; - put?: never; - post?: never; + post: operations["v1SetTopicSubscription"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/top-campaigns": { + "/api/v1/usage": { parameters: { query?: never; header?: never; @@ -2005,14 +2066,19 @@ interface paths { cookie?: never; }; /** - * List the best-performing campaigns - * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. + * Retrieve current usage and limits + * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. * - * `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. + * Every 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. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Two caveats worth reading before you alert on these numbers: + * + * - 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. + * - `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`. + * + * Requires the `usage:read` scope — View your usage totals and billing limits. */ - get: operations["v1ListTopCampaigns"]; + get: operations["v1GetUsage"]; put?: never; post?: never; delete?: never; @@ -2021,7 +2087,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/diagnose": { + "/api/v1/validation-runs/{id}": { parameters: { query?: never; header?: never; @@ -2029,16 +2095,14 @@ interface paths { cookie?: never; }; /** - * Diagnose why mail from a domain is not arriving - * @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. - * - * Everything 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. + * Retrieve a validation run + * @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. * - * `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. + * There 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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `validation:read` scope — View your email validation runs and their results. */ - get: operations["v1DiagnoseDeliverability"]; + get: operations["v1GetValidationRun"]; put?: never; post?: never; delete?: never; @@ -2047,7 +2111,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/domains": { + "/api/v1/validation-runs/{id}/results": { parameters: { query?: never; header?: never; @@ -2055,16 +2119,16 @@ interface paths { cookie?: never; }; /** - * Delivery outcomes per recipient domain - * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. + * List a validation run's results + * @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. * - * This 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. + * No 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. * - * The 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. + * `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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `validation:read` scope — View your email validation runs and their results. */ - get: operations["v1ListRecipientDomainStats"]; + get: operations["v1ListValidationRunResults"]; put?: never; post?: never; delete?: never; @@ -2073,7 +2137,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/dmarc": { + "/api/v1/webhooks": { parameters: { query?: never; header?: never; @@ -2081,27 +2145,31 @@ interface paths { cookie?: never; }; /** - * DMARC aggregate reports for your domains - * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * List webhooks + * @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. * - * The 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. + * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. * - * `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. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + */ + get: operations["v1ListWebhooks"]; + put?: never; + /** + * Create a webhook + * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. * - * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * The 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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1ListDmarcReports"]; - put?: never; - post?: never; + post: operations["v1CreateWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/usage": { + "/api/v1/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -2109,54 +2177,63 @@ interface paths { cookie?: never; }; /** - * Retrieve current usage and limits - * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. - * - * Every 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. - * - * Two caveats worth reading before you alert on these numbers: - * - * - 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. - * - `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`. + * Retrieve a webhook + * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. * - * Requires the `usage:read` scope — View your usage totals and billing limits. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetUsage"]; + get: operations["v1GetWebhook"]; put?: never; post?: never; - delete?: never; + /** + * Delete a webhook + * @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. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + delete: operations["v1DeleteWebhook"]; options?: never; head?: never; - patch?: never; + /** + * Update a webhook + * @description Partial update. 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 part of this response. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + patch: operations["v1UpdateWebhook"]; trace?: never; }; - "/api/v1/projects": { + "/api/v1/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve the authenticated project - * @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. + * Rotate a webhook signing secret + * @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. * - * `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). + * Rotation 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. * - * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * `url`, `event_types` and `status` are unchanged. * - * Requires the `projects:read` scope — View your projects and their settings. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1GetProject"]; - put?: never; - post?: never; + post: operations["v1RotateWebhookSecret"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes": { + "/api/v1/workflows": { parameters: { query?: never; header?: never; @@ -2164,39 +2241,53 @@ interface paths { cookie?: never; }; /** - * List mailboxes - * @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. + * List workflows + * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * This 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. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listMailboxes"]; + get: operations["v1ListWorkflows"]; put?: never; /** - * Create a mailbox - * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. - * - * Three consequences worth knowing before you call it: - * - * - **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. - * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. - * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * Create a workflow + * @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`. * - * Retrying 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. + * Pass `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. * - * `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. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + post: operations["v1CreateWorkflow"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/executions/{execution_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel a workflow execution + * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createMailbox"]; + post: operations["v1CancelWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}": { + "/api/v1/workflows/{id}": { parameters: { query?: never; header?: never; @@ -2204,29 +2295,37 @@ interface paths { cookie?: never; }; /** - * Get a mailbox - * @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. + * Retrieve a workflow + * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["getMailbox"]; + get: operations["v1GetWorkflow"]; put?: never; post?: never; /** - * Delete a mailbox - * @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. - * - * Requires an admin of the project. + * Delete a workflow + * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["deleteMailbox"]; + delete: operations["v1DeleteWorkflow"]; options?: never; head?: never; - patch?: never; + /** + * Update a workflow + * @description Sparse update — omitted fields are left unchanged. + * + * Two 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. + * + * `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + patch: operations["v1UpdateWorkflow"]; trace?: never; }; - "/api/mailboxes/{id}/drafts": { + "/api/v1/workflows/{id}/clone": { parameters: { query?: never; header?: never; @@ -2236,98 +2335,115 @@ interface paths { get?: never; put?: never; /** - * Draft a message with AI - * @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. + * Clone a workflow + * @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. * - * **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. + * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. * - * That 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. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + post: operations["v1CloneWorkflow"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/{id}/executions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a workflow's executions + * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. * - * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1ListWorkflowExecutions"]; + put?: never; + /** + * Start a workflow for a contact + * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. * - * Drafting is capped at 120 requests per hour per project. + * 409 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. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["draftMailboxMessage"]; + post: operations["v1StartWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/messages": { + "/api/v1/workflows/{id}/graph": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a message from a mailbox - * @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. + * Retrieve a workflow's step graph + * @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. * - * **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. + * A 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. * - * **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. + * The 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. * - * Bcc 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. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1GetWorkflowGraph"]; + /** + * Replace a workflow's step graph + * @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. * - * Refusals worth handling by name: + * A 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. * - * - `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. - * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. - * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. - * - `429` — a mailbox may send 60 messages an hour through this endpoint. + * Refused 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. * - * The message is stored as a new conversation on the mailbox, so the reply threads onto it. + * The 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. * - * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["sendMailboxMessage"]; + put: operations["v1ReplaceWorkflowGraph"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords": { + "/api/v1/workflows/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List a mailbox's app passwords - * @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. - * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. - */ - get: operations["listAppPasswords"]; + get?: never; put?: never; /** - * Create an app password - * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. - * - * **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. + * Pause a workflow and cancel its running executions + * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. * - * That 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. + * `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. * - * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createAppPassword"]; + post: operations["v1PauseWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords/{passwordId}": { + "/api/v1/workflows/{id}/resume": { parameters: { query?: never; header?: never; @@ -2336,39 +2452,39 @@ interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an app password - * @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. + * Resume a paused workflow + * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. * - * Requires an admin of the project. An API key is refused with 401. + * Refused 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeAppPassword"]; + post: operations["v1ResumeWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/users/me/projects": { + "/api/v1/workflows/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create a project - * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * Retrieve workflow statistics + * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. * - * Preconditions 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. + * The 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. * - * Requires the `projects:write` scope — Create new projects on your account. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - post: operations["createProject"]; + get: operations["v1GetWorkflowStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; @@ -2395,7 +2511,7 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/topics": { + "/api/webhooks": { parameters: { query?: never; header?: never; @@ -2403,87 +2519,27 @@ interface paths { cookie?: never; }; /** - * List topics - * @description The subjects this project mails about, cursor-paginated and newest first. - * - * Archived 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. - * - * `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. - * - * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. - */ - get: operations["v1ListTopics"]; - put?: never; - /** - * Create a topic - * @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. - * - * `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`. - * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. - */ - post: operations["v1CreateTopic"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/topics/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Retrieve a topic - * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. - */ - get: operations["v1GetTopic"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** - * Update a topic - * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. - * - * `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. + * List user webhooks + * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - patch: operations["v1UpdateTopic"]; - trace?: never; - }; - "/api/v1/topics/{id}/subscriptions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; + get: operations["listWebhooks"]; put?: never; /** - * Subscribe or unsubscribe a contact from a topic - * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. - * - * `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. - * - * `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. + * Create a webhook + * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1SetTopicSubscription"]; + post: operations["createWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/contacts/{id}/topics": { + "/api/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -2491,79 +2547,29 @@ interface paths { cookie?: never; }; /** - * Get a contact's topic preferences - * @description Everything this contact has said they want, as the send path reads it. - * - * `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. - * - * The 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. - * - * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + * Get a webhook + * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetContactTopicPreferences"]; + get: operations["getWebhook"]; put?: never; post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/email-validations": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Validate a batch of email addresses - * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. - * - * The 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. - * - * `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. - * - * The 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. + * Delete a webhook + * @description Hard-delete a webhook. Cascades to all WebhookCall rows. * - * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1ValidateEmails"]; - delete?: never; + delete: operations["deleteWebhook"]; options?: never; head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/lists/{id}/validation-runs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Validate every address on a list - * @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. - * - * This 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`. - * - * A 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. - * - * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + * Update a webhook + * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1StartListValidationRun"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["updateWebhook"]; trace?: never; }; - "/api/v1/validation-runs/{id}": { + "/api/webhooks/{id}/calls": { parameters: { query?: never; header?: never; @@ -2571,14 +2577,12 @@ interface paths { cookie?: never; }; /** - * Retrieve a validation run - * @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. - * - * There 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. + * List recent webhook calls + * @description Cursor-paginated list of recent delivery attempts for a single webhook. * - * Requires the `validation:read` scope — View your email validation runs and their results. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetValidationRun"]; + get: operations["listWebhookCalls"]; put?: never; post?: never; delete?: never; @@ -2587,26 +2591,22 @@ interface paths { patch?: never; trace?: never; }; - "/api/v1/validation-runs/{id}/results": { + "/api/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List a validation run's results - * @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. - * - * No 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. - * - * `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. + * Rotate the webhook signing secret + * @description Generate a new shared secret. Returns the new plaintext secret exactly once. * - * Requires the `validation:read` scope — View your email validation runs and their results. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1ListValidationRunResults"]; - put?: never; - post?: never; + post: operations["rotateWebhookSecret"]; delete?: never; options?: never; head?: never; @@ -2616,2616 +2616,2633 @@ interface paths { } interface components { schemas: { - /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ - Error: { - /** @enum {boolean} */ - success?: false; - error: { - message: string; - code: string; - details?: { - errors: unknown[]; - }; - }; - }; - /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ - Problem: { + /** @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. */ + AddDomainBody: { + domain: string; + /** Format: uuid */ + projectId?: string; /** - * Format: uri - * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. + * @enum {string} */ - type: string; - /** @description Short, stable summary — the same for every occurrence of a `type`. */ - title: string; - /** @description HTTP status code, repeated in the body. */ - status: number; - /** @description Explanation specific to this occurrence. */ - detail?: string; - /** @description Request path the failure occurred on. */ - instance?: string; - /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ - code: string; - /** @description Correlation id — quote it in support requests. */ - request_id?: string; - /** @description Field-level failures. Present on 422 `validation_error` responses. */ - errors?: { - /** @description RFC 6901 JSON Pointer to the offending field. */ - pointer: string; - code: string; - message: string; - }[]; - }; - /** @description Bare success envelope with no payload. */ - SuccessEmpty: { - /** @enum {boolean} */ - success: true; - }; - /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ - IdResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - id: string; - }; + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"]; + /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ + streamDefault?: boolean; }; - /** @description A subscriber/contact within a project. */ - Contact: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; + /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ + AddSuppression: { /** Format: email */ email: string; - subscribed: boolean; - customFields?: { - [key: string]: unknown; - } | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * @default MANUAL + * @enum {string} */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; - /** @description Cursor-paginated list of contacts. */ - ContactListResponse: { - /** @enum {boolean} */ - success: true; + /** @description Campaign counters and engagement over the window. */ + AnalyticsCampaignStatsV1: { + /** @description Campaigns in DRAFT or SCHEDULED. */ + active: number; + average_click_rate: number; + /** @description Percentage, one decimal place. */ + average_open_rate: number; + completed: number; + total: number; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ + AnalyticsTimeseriesV1: { data: { - data: components["schemas"]["Contact"][]; - total: number; - /** @description Cursor for the next page, or null on the last page. */ - nextCursor: string | null; - hasMore: boolean; - }; + bounces: number; + clicks: number; + /** Format: date-time */ + date: string; + delivered: number; + emails: number; + opens: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; - /** @description A reusable email template. */ - Template: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - description?: string | null; - subject: string; - body: string; - /** Format: email */ + /** @description Sent campaigns ranked by open rate. */ + AnalyticsTopCampaignsV1: { + data: { + click_rate: number; + clicked: number; + /** Format: uuid */ + id: string; + open_rate: number; + opened: number; + sent: number; + subject: string; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description The time range this response was computed over, after the 90-day clamp. */ + AnalyticsWindowV1: { + /** Format: date-time */ from: string; - fromName?: string | null; - /** Format: email */ - replyTo?: string | null; - /** @enum {string} */ - emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; + /** Format: date-time */ + to: string; + }; + /** @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. */ + ApiKey: { /** * Format: date-time * @description ISO 8601 datetime string */ createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description Cursor-paginated list of templates. */ - TemplateListResponse: { - /** @enum {boolean} */ - success: true; - data: { - data: components["schemas"]["Template"][]; - total: number; - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - hasMore: boolean; - }; - }; - /** @description A reusable fragment of template markup. */ - Snippet: { /** Format: uuid */ - id: string; + domainId: string | null; /** Format: uuid */ - projectId: string; - /** @description The literal identifier a template includes with `{{> name}}`. */ - name: string; - description?: string | null; - /** @description Template markup. Values it interpolates are escaped like any other. */ - body: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; + id: string; + /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ + lastFour: string; /** * Format: date-time * @description ISO 8601 datetime string */ - updatedAt: string; - }; - /** @description Cursor-paginated list of snippets. */ - SnippetListResponse: { - /** @enum {boolean} */ - success: true; - data: { - data: components["schemas"]["Snippet"][]; - total: number; - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - hasMore: boolean; - }; - }; - /** - * @description Delivery lifecycle of the message. Engagement is reported separately. - * @enum {string} - */ - EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ - Domain: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - verified: boolean; - region?: string | null; - dkim?: { - type: string; - name: string; - value: string; - }[]; - /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + lastUsedAt: string | null; /** - * @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 {string|null} + * @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 {string} */ - stream?: "TRANSACTIONAL" | "MARKETING" | null; - /** @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). */ - streamDefault?: boolean; - /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ - defaultFromAddress?: string | null; + legacyGrantPreset: "FULL" | "SENDING_ONLY"; /** - * Format: date-time - * @description ISO 8601 datetime string + * @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 {string} */ - createdAt: string; + mode: "LIVE" | "TEST"; + name: string; + /** Format: uuid */ + projectId: string; /** * Format: date-time - * @description ISO 8601 datetime string + * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. */ - updatedAt: string; + revokedAt: string | null; + /** @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. */ + scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; }; - /** @description List of all domains for the auth'd project. */ - DomainListResponse: { + /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ + ApiKeyListResponse: { + data: components["schemas"]["ApiKey"][]; /** @enum {boolean} */ success: true; - data: components["schemas"]["Domain"][]; - }; - /** @description Outcome of a verification check against SES. */ - DomainVerificationStatus: { - verified: boolean; - mxRecords?: string[]; - dkim?: { - type: string; - name: string; - value: string; - }[]; - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; }; - /** @description A sent (or queued) transactional email. */ - Email: { + /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ + AppPassword: { + /** Format: date-time */ + createdAt: string; /** Format: uuid */ id: string; - /** Format: uuid */ - projectId: string; - from: string; - to: string; - subject: string; - status: components["schemas"]["EmailDeliveryStatus"]; + /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ + lastFour: string; /** * Format: date-time - * @description First open, or null. + * @description Null until a mail client has authenticated with it at least once. */ - openedAt: string | null; + lastUsedAt: string | null; + /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ + name: string; + /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ + scopes: ("imap" | "smtp")[]; + }; + /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ + AppPasswordReveal: { + /** Format: uuid */ + id: string; /** * Format: date-time - * @description First click, or null. + * @description When the link stops working. Five minutes after creation; the password itself does not expire. */ - clickedAt: string | null; + revealExpiresAt: string; /** - * Format: date-time - * @description Spam complaint, or null. + * Format: uri + * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. */ - complainedAt: string | null; - /** @description Total opens recorded. */ - opens: number; - /** @description Total clicks recorded. */ - clicks: number; - tags: string[]; - error?: string | null; + revealUrl: string; + }; + /** @description Body for PATCH /api/domains/{id}. */ + AssignDomainStream: { /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: email + * @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. */ - createdAt: string; + defaultFromAddress?: string | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. + * @enum {string|null} */ - updatedAt: string; - }; - /** @description One transition in a message's delivery history. */ - EmailEvent: { - /** Format: uuid */ - id: string; - status: components["schemas"]["EmailDeliveryStatus"]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - /** @description A transactional email together with its delivery history. */ - EmailWithEvents: components["schemas"]["Email"] & { - /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ - events: components["schemas"]["EmailEvent"][]; - }; - /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ - SendEmailRecipientResult: { - contact: { - /** Format: uuid */ - id: string; - /** Format: email */ - email: string; - }; - /** Format: uuid */ - email: string; - }; - /** @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`. */ - SendEmailData: { - emails: components["schemas"]["SendEmailRecipientResult"][]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - /** @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. */ - SendEmailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["SendEmailData"]; + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @description Make this the project's default identity for its stream, demoting whichever held it. */ + streamDefault?: boolean; }; /** @description Per-row result in a batch send response. */ BatchEntryResult: { - index: number; - /** @enum {string} */ - status: "ok" | "error"; data?: components["schemas"]["SendEmailData"]; error?: { - message: string; code: string; + message: string; }; - }; - /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ - BatchSendResponse: { - success: boolean; - data: components["schemas"]["BatchEntryResult"][]; + index: number; + /** @enum {string} */ + status: "ok" | "error"; }; /** @description Batch send wrapper. Up to 100 entries. */ BatchSendBody: { emails: components["schemas"]["SendEmail"][]; }; - /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ - SendEmail: { - to: string | { - name?: string; - /** Format: email */ - email: string; - } | (string | { - name?: string; - /** Format: email */ - email: string; - })[]; - subject?: string; - body?: string; - /** Format: uuid */ - template?: string; - subscribed?: boolean; - name?: string; - from?: string | { - name?: string; - /** Format: email */ - email: string; - }; - /** Format: email */ - reply?: string; - headers?: { - [key: string]: string; - }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; - }; - attachments?: { - filename: string; - content: string; - contentType: string; - contentId?: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - }[]; - tags?: string[]; - cc?: string[]; - bcc?: string[]; - }; - /** @description Bulk create up to 1000 contacts. */ - ContactBulkCreateBody: { - contacts: components["schemas"]["CreateContact"][]; - }; - /** @description Body for POST /api/contacts and /api/contacts/upsert. */ - CreateContact: { - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - customFields?: { - [key: string]: unknown; - }; - }; - /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ - ContactBulkDeleteBody: { - ids?: string[]; - emails?: string[]; - }; - /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ - UpdateContactBody: { - subscribed?: boolean; - customFields?: { - [key: string]: unknown; - }; + /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ + BatchSendResponse: { + data: components["schemas"]["BatchEntryResult"][]; + success: boolean; }; - /** @description A single suppressed-email record. */ - Suppression: { + /** @description A campaign as exposed on the v1 API. */ + CampaignV1: { + /** @enum {string} */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + /** Format: date-time */ + created_at: string; /** Format: uuid */ id: string; /** Format: uuid */ - projectId: string; - /** Format: email */ - email: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + list_id: string | null; + name: string; + /** Format: date-time */ + scheduled_at: string | null; + /** Format: date-time */ + sent_at: string | null; + stats: { + bounced: number; + clicked: number; + delivered: number; + opened: number; + sent: number; + total_recipients: number; + }; /** @enum {string} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD"; + status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; + subject: string; + /** Format: uuid */ + topic_id: string | null; + }; + /** @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`. */ + CampaignV1Create: { + audience_condition?: components["schemas"]["FilterConditionV1"]; /** - * Format: date-time - * @description ISO 8601 datetime string + * @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 {string} */ - createdAt: string; - }; - /** @description Cursor-paginated list of suppressions. */ - SuppressionListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Suppression"][]; - nextCursor?: string | null; - cursor?: string | null; - hasMore?: boolean; - }; - /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ - SuppressionCheckResponse: { - suppressed: boolean; - /** @enum {string} */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + body: string; + description?: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * @default MARKETING + * @enum {string} */ - createdAt?: string; - }; - /** @description A user-managed outbound webhook. */ - Webhook: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** Format: uri */ - url: string; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - consecutiveFailures: number; + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: email + * @description Sender address. Its domain must be verified for this project. */ - disabledAt?: string | null; - lastFour?: string; + from: string; + from_name?: string | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. */ - createdAt: string; + list_id?: string; + name: string; + /** Format: email */ + reply_to?: string | null; + /** Format: uuid */ + segment_id?: string; + subject: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @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. */ - updatedAt: string; + topic_id?: string | null; }; - /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ - WebhookCreateResponse: { + /** @description Acknowledgement that a campaign was deleted. */ + CampaignV1Deleted: { /** @enum {boolean} */ - success: true; - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { - /** @description Plaintext shared secret. Returned ONCE on create. */ - secret: string; - }; + deleted: true; + /** Format: uuid */ + id: string; }; - /** @description List of webhooks for the auth'd project. */ - WebhookListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Webhook"][]; + /** @description A campaign recipient whose send did not complete. */ + CampaignV1Failure: { + /** Format: uuid */ + contact_id: string; + /** @description The recipient the send was for. */ + email: string; + /** Format: date-time */ + failed_at: string; + /** + * Format: uuid + * @description Ledger row id. Pass the last one as `after` to page. + */ + id: string; + reason: string | null; }; - /** @description Single webhook (no secret). */ - WebhookGetResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Webhook"]; + /** @description Cursor-paginated list of a campaign's failed sends. */ + CampaignV1FailureList: { + data: components["schemas"]["CampaignV1Failure"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + /** @description Every FAILED row on this campaign, not just this page. */ + total: number; }; - /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ - WebhookRotateSecretResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - id: string; - /** @description New plaintext shared secret. */ - secret: string; - }; + /** @description Cursor-paginated list of campaigns. */ + CampaignV1List: { + data: components["schemas"]["CampaignV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description An attempted webhook delivery. */ - WebhookCall: { + /** @description Acknowledgement that a retry of a campaign's failed sends began. */ + CampaignV1RetryFailed: { /** Format: uuid */ id: string; - /** Format: uuid */ - webhookId: string; - eventType: string; - payload: { - [key: string]: unknown; - }; - /** @enum {string} */ - status: "PENDING" | "SUCCESS" | "FAILED"; - attempt: number; - responseStatus?: number | null; - responseBody?: string | null; + /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ + queued: number; + }; + /** @description Body for POST /api/v1/campaigns/{id}/send. */ + CampaignV1Send: { /** * Format: date-time - * @description ISO 8601 datetime string + * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. */ - createdAt: string; - }; - /** @description Cursor-paginated list of recent calls for a single webhook. */ - WebhookCallsListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["WebhookCall"][]; - nextCursor?: string | null; - cursor?: string | null; - hasMore?: boolean; - }; - /** @description Response from POST /api/track. */ - TrackEventResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - contact: string; - /** Format: uuid */ - event: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - }; - /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ - VerifyEmailResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: email */ - email: string; - valid: boolean; - reason?: string; - } & { - [key: string]: unknown; - }; - }; - /** @description Cursor-paginated list of emails. */ - EmailListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Email"][]; - nextCursor?: string | null; - }; - /** @description A single email. */ - EmailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Email"]; - }; - /** @description One email and its delivery history. */ - EmailDetailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["EmailWithEvents"]; - }; - /** @description Result of a list-subscribe call. */ - ListSubscribeResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - membershipId: string; - /** @enum {string} */ - status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; - /** @description True when the membership row did not exist before this call. */ - created: boolean; - /** - * @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 {string|null} - */ - previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; - /** @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. */ - confirmToken?: string; - }; + scheduled_for?: string; }; - /** @description Echoes the address that was unsubscribed. */ - ListUnsubscribeResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: email */ - email: string; - }; + /** @description Materialized delivery and engagement counters for one campaign. */ + CampaignV1Stats: { + bounce_rate: number; + bounced: number; + click_rate: number; + clicked: number; + delivered: number; + delivery_rate: number; + open_rate: number; + opened: number; + sent: number; + total_recipients: number; }; - /** @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. */ - ApiKey: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ - lastFour: string; + /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ + CampaignV1Update: { + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** @enum {string} */ + audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + body?: string; + description?: string; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** - * @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 {string} + * Format: email + * @description Sender address. Its domain must be verified for this project. */ - legacyGrantPreset: "FULL" | "SENDING_ONLY"; + from?: string; + from_name?: string | null; /** - * @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 {string} + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. */ - mode: "LIVE" | "TEST"; - /** @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. */ - scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + list_id?: string; + name?: string; + /** Format: email */ + reply_to?: string | null; /** Format: uuid */ - domainId: string | null; + segment_id?: string; + subject?: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @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. */ - lastUsedAt: string | null; + topic_id?: string | null; + }; + /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ + ComposeMailboxMessage: { + bcc?: string[]; + body: string; + cc?: string[]; + subject: string; + to: string[]; + }; + /** @description A subscriber/contact within a project. */ + Contact: { /** * Format: date-time * @description ISO 8601 datetime string */ createdAt: string; + customFields?: { + [key: string]: unknown; + } | null; + /** Format: email */ + email: string; + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + subscribed: boolean; /** * Format: date-time - * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. + * @description ISO 8601 datetime string */ - revokedAt: string | null; + updatedAt: string; }; - /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ - ApiKeyListResponse: { + /** @description Bulk create up to 1000 contacts. */ + ContactBulkCreateBody: { + contacts: components["schemas"]["CreateContact"][]; + }; + /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ + ContactBulkDeleteBody: { + emails?: string[]; + ids?: string[]; + }; + /** @description Cursor-paginated list of contacts. */ + ContactListResponse: { + data: { + data: components["schemas"]["Contact"][]; + hasMore: boolean; + /** @description Cursor for the next page, or null on the last page. */ + nextCursor: string | null; + total: number; + }; /** @enum {boolean} */ success: true; - data: components["schemas"]["ApiKey"][]; }; - ProjectRecord: { + /** @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. */ + ContactTopicPreferencesV1: { + contact_id: string; + /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ + subscribed: boolean; + topics: { + /** @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. */ + key: string; + name: string; + pending: boolean; + /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ + subscribed: boolean; + topic_id: string; + }[]; + }; + /** @description A contact as exposed on the v1 API. */ + ContactV1: { + /** Format: date-time */ + created_at: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + email: string; /** Format: uuid */ id: string; - name: string; - disabled: boolean; - disabledReason: string | null; - /** @description Local-part of the sandbox quick-start sender; null until first derived. */ - sandboxHandle: string | null; - stripeCustomerId: string | null; - stripeSubscriptionId: string | null; - billingLimitWorkflows: number | null; - billingLimitCampaigns: number | null; - billingLimitTransactional: number | null; - billingLimitInbound: number | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - sesRegion: string | null; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - organizationId: string | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + subscribed: boolean; + /** Format: date-time */ + updated_at: string; }; - /** @description Cursor-paginated list of campaigns. */ - CampaignV1List: { - data: components["schemas"]["CampaignV1"][]; + /** @description Body for POST /api/v1/contacts. */ + ContactV1Create: { + /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + }; + /** @description Acknowledgement that a contact was deleted. */ + ContactV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactV1List: { + data: components["schemas"]["ContactV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A campaign as exposed on the v1 API. */ - CampaignV1: { + /** @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. */ + ContactV1Update: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + subscribed?: boolean; + }; + CreateApiKeyBody: { /** Format: uuid */ - id: string; - name: string; - /** @enum {string} */ - status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; - subject: string; + domainId?: string | null; /** @enum {string} */ - audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - /** Format: uuid */ - list_id: string | null; - /** Format: uuid */ - topic_id: string | null; - /** Format: date-time */ - scheduled_at: string | null; - /** Format: date-time */ - sent_at: string | null; - /** Format: date-time */ - created_at: string; - stats: { - total_recipients: number; - sent: number; - delivered: number; - opened: number; - clicked: number; - bounced: number; - }; - }; - /** @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`. */ - CampaignV1Create: { - name: string; - description?: string; - subject: string; - body: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from: string; - from_name?: string | null; - /** Format: email */ - reply_to?: string | null; + legacyGrantPreset?: "FULL" | "SENDING_ONLY"; /** - * @default MARKETING + * @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 {string} */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + mode?: "LIVE" | "TEST"; + name: string; + /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ + scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + }; + /** @description Body for POST /api/mailboxes/:id/app-passwords. */ + CreateAppPassword: { + name: string; /** - * @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 {string} + * @default [ + * "imap", + * "smtp" + * ] */ - audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** Format: uuid */ - segment_id?: string; + scopes: ("imap" | "smtp")[]; + }; + /** @description Body for POST /api/contacts and /api/contacts/upsert. */ + CreateContact: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + customFields?: { + [key: string]: unknown; + }; + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + }; + CreateMailboxBody: { + displayName?: string; /** * Format: uuid - * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + * @description A VERIFIED domain belonging to this project. */ - list_id?: string; + domainId: string; + /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ + localPart: string; /** * Format: uuid - * @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. + * @description Defaults to the project the credential resolves to. Naming a different one is refused. */ - topic_id?: string | null; - }; - /** @description A filter condition: one or more groups combined with `logic`. */ - FilterConditionV1: { - /** @enum {string} */ - logic: "AND" | "OR"; - groups: components["schemas"]["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. */ - FilterGroupV1: { - filters: components["schemas"]["SegmentFilterV1"][]; - conditions?: components["schemas"]["FilterConditionV1"]; + projectId?: string; + /** @description NOT IMPLEMENTED — sending any value answers 400. */ + quotaBytes?: number; }; - /** @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). */ - SegmentFilterV1: { - field: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; - value?: unknown; - /** @enum {string} */ - unit?: "days" | "hours" | "minutes"; + /** @description Body for POST /api/snippets. */ + CreateSnippet: { + body: string; + description?: string | null; + name: string; }; - /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ - CampaignV1Update: { - name?: string; + /** @description Body for POST /api/templates. */ + CreateTemplate: { + body: string; description?: string; - subject?: string; - body?: string; /** - * Format: email - * @description Sender address. Its domain must be verified for this project. + * @default MARKETING + * @enum {string} */ - from?: string; - from_name?: string | null; + emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ - reply_to?: string | null; - /** @enum {string} */ - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - /** @enum {string} */ - audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** Format: uuid */ - segment_id?: string; - /** - * Format: uuid - * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. - */ - list_id?: string; - /** - * Format: uuid - * @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. - */ - topic_id?: string | null; + from: string; + fromName?: string | null; + name: string; + /** Format: email */ + replyTo?: string | null; + subject: string; }; - /** @description Acknowledgement that a campaign was deleted. */ - CampaignV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; + /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ + CreateWebhook: { + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** Format: uri */ + url: string; }; - /** @description Body for POST /api/v1/campaigns/{id}/send. */ - CampaignV1Send: { - /** + /** @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. */ + DeliverabilityDiagnosisV1: { + address: string | null; + /** Format: date-time */ + checked_at: string; + domain: string; + /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ + findings: components["schemas"]["DeliverabilityFindingV1"][]; + identity: components["schemas"]["DeliverabilityIdentityV1"]; + recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; + suppression: components["schemas"]["DeliverabilitySuppressionV1"]; + }; + /** + * @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 {string} + */ + DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; + /** @description One diagnosed problem, with its fix. */ + DeliverabilityFindingV1: { + /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ + code: string; + /** @description What to do about it. */ + remedy: string; + severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; + /** @description What is wrong, in one sentence. */ + summary: string; + }; + /** @description The sending identity's DNS health, as last refreshed. */ + DeliverabilityIdentityV1: { + /** + * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. + * @enum {string|null} + */ + dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description The DMARC policy published at `_dmarc.`. + * @enum {string|null} + */ + dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** * Format: date-time - * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. + * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. */ - scheduled_for?: string; + last_checked_at: string | null; + mail_from_domain: string | null; + /** @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. */ + mail_from_domain_status: string | null; + /** + * @description Inbound receiving only. Null unless the domain has receiving enabled. + * @enum {string|null} + */ + mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description Whether this project has a domain record at all. False makes every other field null. */ + registered: boolean; + /** + * @description SPF alignment for the sending identity. + * @enum {string|null} + */ + spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + verified: boolean; }; - /** @description Materialized delivery and engagement counters for one campaign. */ - CampaignV1Stats: { - total_recipients: number; - sent: number; - delivered: number; - opened: number; - clicked: number; + /** @description Delivery outcomes over the requested window. */ + DeliverabilityRecentDeliveryV1: { + /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ + bounce_rate: number | null; bounced: number; - open_rate: number; - click_rate: number; - bounce_rate: number; - delivery_rate: number; - }; - /** @description Cursor-paginated list of a campaign's failed sends. */ - CampaignV1FailureList: { - data: components["schemas"]["CampaignV1Failure"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - /** @description Every FAILED row on this campaign, not just this page. */ - total: number; - }; - /** @description A campaign recipient whose send did not complete. */ - CampaignV1Failure: { + complained: number; + complaint_rate: number | null; + delivered: number; + failed: number; /** - * Format: uuid - * @description Ledger row id. Pass the last one as `after` to page. + * @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 {string} */ - id: string; - /** Format: uuid */ - contact_id: string; - /** @description The recipient the send was for. */ - email: string; - reason: string | null; - /** Format: date-time */ - failed_at: string; + scope: "project"; + sent: number; + window_days: number; }; - /** @description Acknowledgement that a retry of a campaign's failed sends began. */ - CampaignV1RetryFailed: { - /** Format: uuid */ + /** @description Null unless the request named an `address`. */ + DeliverabilitySuppressionV1: { + /** @enum {string|null} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; + /** @enum {string|null} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; + suppressed: boolean; + /** Format: date-time */ + suppressed_at: string | null; + } | null; + /** @description One DMARC aggregate (RUA) report. */ + DmarcReportV1: { + fail_count: number; id: string; - /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ - queued: number; + /** @description The reporting receiver, e.g. `google.com`. */ + org_name: string; + /** @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. */ + pass_count: number; + /** @description The domain of yours the report is about. */ + policy_domain: string; + /** Format: date-time */ + range_begin: string; + /** Format: date-time */ + range_end: string; + /** Format: date-time */ + received_at: string; + /** @description The receiver's own id for this report. */ + report_id: string; + /** @description Per-sending-source rows, as the receiver reported them. */ + sources: { + count: number; + disposition: string; + dkim: string; + header_from: string; + source_ip: string; + spf: string; + }[]; + total_count: number; }; - /** @description Cursor-paginated list of segments. */ - SegmentV1List: { - data: components["schemas"]["SegmentV1"][]; + /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ + DmarcReportV1List: { + data: components["schemas"]["DmarcReportV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A segment as exposed on the v1 API. */ - SegmentV1: { + /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ + Domain: { + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ + defaultFromAddress?: string | null; + dkim?: { + name: string; + type: string; + value: string; + }[]; /** Format: uuid */ id: string; + /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; name: string; - description: string | null; - /** @enum {string} */ - type: "DYNAMIC" | "STATIC"; - condition: components["schemas"]["FilterConditionV1"] | null; - track_membership: boolean; - member_count: number; + /** Format: uuid */ + projectId: string; + region?: string | null; + /** + * @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 {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @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). */ + streamDefault?: boolean; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + verified: boolean; + }; + /** @description List of all domains for the auth'd project. */ + DomainListResponse: { + data: components["schemas"]["Domain"][]; + /** @enum {boolean} */ + success: true; + }; + /** @description A sending domain as exposed on the v1 API. */ + DomainV1: { /** Format: date-time */ created_at: string; + default_from_address: string | null; + dkim_verified: boolean; + domain: string; + /** Format: uuid */ + id: string; + mail_from_domain: string | null; + /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ + mail_from_domain_status: string | null; + region: string | null; + stream: components["schemas"]["SendingStream"] & (string | null); + stream_default: boolean; /** Format: date-time */ updated_at: string; + verified: boolean; }; - /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ - SegmentV1Create: { - name: string; - description?: string; + /** @description Body for POST /api/v1/domains. */ + DomainV1Create: { + domain: string; /** - * @default DYNAMIC + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. * @enum {string} */ - type: "DYNAMIC" | "STATIC"; - condition?: components["schemas"]["FilterConditionV1"]; - /** - * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. - * @default false - */ - track_membership: boolean; - }; - /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ - SegmentV1Update: { - name?: string; - description?: string; - condition?: components["schemas"]["FilterConditionV1"]; - track_membership?: boolean; + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"] & unknown; + /** @description Make this the project's default identity for `stream`. Requires `stream`. */ + stream_default?: boolean; }; - /** @description Acknowledgement that a segment was deleted. */ - SegmentV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Acknowledgement that a sending domain was removed. */ + DomainV1Deleted: { /** @enum {boolean} */ deleted: true; + /** Format: uuid */ + id: string; }; - /** @description Cursor-paginated list of the contacts belonging to a segment. */ - SegmentContactV1List: { - data: components["schemas"]["SegmentContactV1"][]; + /** @description Cursor-paginated list of sending domains. */ + DomainV1List: { + data: components["schemas"]["DomainV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A contact belonging to a segment. */ - SegmentContactV1: { - /** Format: uuid */ - id: string; - email: string; - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: { - [key: string]: unknown; - }; - /** Format: date-time */ - created_at: string; + /** @description Outcome of a verification check against SES. */ + DomainVerificationStatus: { + dkim?: { + name: string; + type: string; + value: string; + }[]; + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + mxRecords?: string[]; + verified: boolean; }; - /** @description Cursor-paginated list of workflows. */ - WorkflowV1List: { - data: components["schemas"]["WorkflowV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ + DraftMailboxMessage: { + brief?: string; + draft?: string; + instruction?: string; + /** @enum {string} */ + mode: "draft" | "rewrite" | "subject"; + recipientContext?: string; + senderAddress?: string; + /** @enum {string} */ + tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; }; - /** @description An automation workflow as exposed on the v1 API. */ - WorkflowV1: { + /** @description A sent (or queued) transactional email. */ + Email: { + /** + * Format: date-time + * @description Bounced, or null. + */ + bouncedAt: string | null; + /** + * Format: date-time + * @description First click, or null. + */ + clickedAt: string | null; + /** @description Total clicks recorded. */ + clicks: number; + /** + * Format: date-time + * @description Spam complaint, or null. + */ + complainedAt: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description Accepted by the recipient's server, or null. + */ + deliveredAt: string | null; + error?: string | null; + from: string; /** Format: uuid */ id: string; - name: string; - description: string | null; - enabled: boolean; - /** @enum {string} */ - trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; - /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ - event_name: string | null; - allow_reentry: boolean; - max_executions_per_hour: number | null; - /** @description Incremented on every structural (step/transition) change. */ - version: number; - /** @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. */ - step_count: number; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description Body for POST /api/v1/workflows. */ - WorkflowCreateV1: { - name: string; - description?: string; - trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; - /** @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. */ - event_name?: string; - /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ - interval_ms?: number; - /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ - enabled?: boolean; - allow_reentry?: boolean; - /** @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. */ - sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; - }; - /** - * @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 {string} - */ - WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; - /** @description One step of a linear workflow sequence. */ - WorkflowSequenceStepV1: { - type: components["schemas"]["WorkflowSequenceStepTypeV1"]; - /** @description Human-readable label, e.g. `Day 0: welcome`. */ - name: string; - /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ - config: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; /** - * Format: uuid - * @description For `SEND_EMAIL`: a template in this project. + * Format: date-time + * @description First open, or null. */ - template_id?: string; + openedAt: string | null; + /** @description Total opens recorded. */ + opens: number; + /** Format: uuid */ + projectId: string; + /** + * Format: date-time + * @description Handed to the provider, or null. + */ + sentAt: string | null; + status: components["schemas"]["EmailDeliveryStatus"]; + subject: string; + tags: string[]; + to: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; }; /** - * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @description Delivery lifecycle of the message. Engagement is reported separately. * @enum {string} */ - WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; - /** @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. */ - WorkflowUpdateV1: { - name?: string; - description?: string; - trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; - event_name?: string; - /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ - interval_ms?: number; - enabled?: boolean; - allow_reentry?: boolean; - /** @description Per-workflow start rate cap. `null` removes the cap. */ - max_executions_per_hour?: number | null; - /** @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. */ - sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** @description One email and its delivery history. */ + EmailDetailResponse: { + data: components["schemas"]["EmailWithEvents"]; + /** @enum {boolean} */ + success: true; }; - /** @description Confirmation that a workflow was deleted. */ - WorkflowDeletedV1: { + /** @description One transition in a message's delivery history. */ + EmailEvent: { /** Format: uuid */ id: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description Cursor-paginated list of emails. */ + EmailListResponse: { + data: components["schemas"]["Email"][]; + nextCursor?: string | null; /** @enum {boolean} */ - deleted: true; + success: true; }; - /** @description Cursor-paginated list of workflow executions, newest first. */ - WorkflowExecutionV1List: { - data: components["schemas"]["WorkflowExecutionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description A single email. */ + EmailResponse: { + data: components["schemas"]["Email"]; + /** @enum {boolean} */ + success: true; }; - /** @description One contact's run through a workflow. */ - WorkflowExecutionV1: { - /** Format: uuid */ + /** @description Receipt for a sandbox test send. */ + EmailTestV1: { + /** + * Format: email + * @description This project's sandbox sender — resolved server-side, never from the body. + */ + from: string; + /** + * Format: uuid + * @description The Email row this send created. + */ id: string; - /** Format: uuid */ - workflow_id: string; - /** Format: uuid */ - contact_id: string; - /** @enum {string} */ - status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - /** Format: uuid */ - current_step_id: string | null; - exit_reason: string | null; - /** Format: date-time */ - started_at: string; - /** Format: date-time */ - completed_at: string | null; + /** + * @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 {boolean} + */ + sandbox: true; + /** + * @description Delivery status at the moment of the response — `PENDING` for a send still queued. + * @enum {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; }; - /** @description Body for POST /api/v1/workflows/{id}/executions. */ - WorkflowExecutionStartV1: { + /** @description Receipt for a single transactional send. */ + EmailV1: { + /** + * Format: email + * @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. + */ + from: string; /** * Format: uuid - * @description Contact to enter the workflow. Must belong to this project. + * @description The Email row this send created. Quote it in support requests. */ - contact_id: string; - /** @description Extra variables merged into the contact's data for this run. */ - context?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + id: string; + /** + * @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 {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; }; - /** @description Execution, email and conversion totals for one workflow. */ - WorkflowStatsV1: { - /** Format: uuid */ - workflow_id: string; - name: string; - enabled: boolean; - trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; - /** @description Steps in the workflow's graph, trigger step included. */ - step_count: number; - total: number; - /** @description Execution counts keyed by status; a status with no executions is absent. */ - by_status: { - [key: string]: number; - }; - /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ - completion_rate: number | null; - avg_duration_ms: number | null; - emails: { - sent: number; - opened: number; - clicked: number; + EmailValidationBatchRequestV1: { + /** @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. */ + emails: string[]; + }; + /** @description One verdict per address, in the order they were given. */ + EmailValidationBatchV1: { + results: components["schemas"]["EmailValidationV1"][]; + }; + /** @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. */ + EmailValidationResultListV1: { + data: (components["schemas"]["EmailValidationV1"] & { + contact_id: string | null; + })[]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One bulk validation run over a list. */ + EmailValidationRunV1: { + /** Format: date-time */ + completed_at: string | null; + /** Format: date-time */ + created_at: string; + deliverable_count: number; + /** @description Set only on `failed`. Prose for an operator; never parse it. */ + failure_reason: string | null; + id: string; + list_id: string | null; + /** @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. */ + processed_count: number; + risky_count: number; + /** Format: date-time */ + started_at: string | null; + /** @enum {string} */ + status: "pending" | "running" | "completed" | "failed"; + undeliverable_count: number; + }; + /** @description One address's verdict, with the evidence behind it. */ + EmailValidationV1: { + email: string; + /** @description The domain publishes MX records. */ + has_mx_records: boolean; + /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ + is_disposable: boolean; + /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ + is_personal: boolean; + /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ + is_role_address: boolean; + /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ + reasons: string[]; + verdict: components["schemas"]["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 {string} + */ + EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; + /** @description A transactional email together with its delivery history. */ + EmailWithEvents: components["schemas"]["Email"] & { + /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ + events: components["schemas"]["EmailEvent"][]; + }; + /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ + Error: { + error: { + code: string; + details?: { + errors: unknown[]; + }; + message: string; }; - conversions: { - /** Format: uuid */ - goal_id: string; - name: string; - event_name: string; + /** @enum {boolean} */ + success?: false; + }; + /** @description Every distinct event name in the project, most frequent first. */ + EventNamesV1: { + data: string[]; + }; + /** @description Per-name event counts over the applied window. */ + EventStatsV1: { + data: { count: number; + name: string; }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; - /** @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. */ - WorkflowGraphV1: { - /** Format: uuid */ - workflow_id: string; - /** @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. */ - version: number; - steps: components["schemas"]["WorkflowStepReadV1"][]; - transitions: components["schemas"]["WorkflowTransitionV1"][]; - }; - /** @description One node of a workflow graph, as read. */ - WorkflowStepReadV1: { + /** @description Body for POST /api/v1/events. */ + EventTrackV1: { /** * Format: uuid - * @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. + * @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. */ - id: string; + contact_id?: string; + /** @description Event name, e.g. `user.signup`. */ name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** @enum {string} */ - type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; - /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ - config: { + /** @description Arbitrary event payload. */ + payload?: { [key: string]: string | number | boolean | { [key: string]: unknown; } | unknown[] | null; }; }; - /** @description Where this step sits on the editor canvas. */ - WorkflowStepPositionV1: { - x: number; - y: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - /** @description One directed edge between two steps. */ - WorkflowTransitionV1: { - /** - * Format: uuid - * @description Caller-chosen on a write, exactly like a step id. - */ - id: string; + /** @description A recorded custom event. */ + EventV1: { /** Format: uuid */ - from_step_id: string; + contact_id: string | null; + /** Format: date-time */ + created_at: string; /** Format: uuid */ - to_step_id: string; - /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ - condition: string | number | boolean | { + email_id: string | null; + /** Format: uuid */ + id: string; + name: string; + /** @description The payload recorded with the event, or null. */ + payload: { [key: string]: unknown; - } | unknown[] | null; - /** @description Evaluation order among the edges leaving one step; lowest first. */ - priority: number; + } | null; }; - /** @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. */ - WorkflowGraphReplaceV1: { - /** @description The complete step set. Exactly one must be a `TRIGGER`. */ - steps: components["schemas"]["WorkflowStepV1"][]; - /** @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. */ - transitions: components["schemas"]["WorkflowTransitionV1"][]; + /** @description Cursor-paginated list of events, newest first. */ + EventV1List: { + data: components["schemas"]["EventV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description One node of a workflow graph. */ - WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; - /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ - WorkflowTriggerStepV1: { - /** - * Format: uuid - * @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. - */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "TRIGGER"; - config: { - eventName?: string; - intervalMs?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description A filter condition: one or more groups combined with `logic`. */ + FilterConditionV1: { + groups: components["schemas"]["FilterGroupV1"][]; + /** @enum {string} */ + logic: "AND" | "OR"; + }; + /** @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. */ + FilterGroupV1: { + conditions?: components["schemas"]["FilterConditionV1"]; + filters: components["schemas"]["SegmentFilterV1"][]; + }; + /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ + IdResponse: { + data: { + /** Format: uuid */ + id: string; }; + /** @enum {boolean} */ + success: true; }; - /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ - WorkflowSendEmailStepV1: { + /** @description Body for POST /api/lists/{id}/subscribe. */ + ListSubscribe: { /** - * Format: uuid - * @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. + * @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. + * @default false */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "SEND_EMAIL"; - config: { + allowResubscribe: boolean; + /** @description Custom fields to upsert onto the contact as part of subscribing. */ + data?: { + [key: string]: unknown; + }; + /** Format: email */ + email: string; + }; + /** @description Result of a list-subscribe call. */ + ListSubscribeResponse: { + data: { + /** @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. */ + confirmToken?: string; + /** @description True when the membership row did not exist before this call. */ + created: boolean; /** Format: uuid */ - templateId?: string; - subject?: string; - body?: string; - recipient?: { - /** @enum {string} */ - type: "CONTACT" | "CUSTOM"; - /** Format: email */ - customEmail?: string; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + membershipId: string; + /** + * @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 {string|null} + */ + previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; + /** @enum {string} */ + status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; }; + /** @enum {boolean} */ + success: true; }; - /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ - WorkflowDelayStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Body for POST /api/lists/{id}/unsubscribe. */ + ListUnsubscribe: { + /** Format: email */ + email: string; + }; + /** @description Echoes the address that was unsubscribed. */ + ListUnsubscribeResponse: { + data: { + /** Format: email */ + email: string; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description A subscriber list as exposed on the v1 API. */ + ListV1: { + /** Format: uuid */ + confirmation_template_id: string | null; + /** Format: date-time */ + created_at: string; + description: string | null; + double_opt_in: boolean; + /** Format: uuid */ id: string; + /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ + member_count: number; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + redirect_url: string | null; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/lists. */ + ListV1Create: { + /** Format: uuid */ + confirmation_template_id?: string | null; + description?: string | null; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @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. + * @default false */ - template_id?: string | null; + double_opt_in: boolean; + name: string; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Format: uri + * @description Where a confirmed contact is sent after following the confirmation link. */ - type: "DELAY"; - config: { - amount?: number; - /** @enum {string} */ - unit?: "minutes" | "hours" | "days"; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + redirect_url?: string | null; }; - /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ - WorkflowWaitForEventStepV1: { + /** @description Acknowledgement that a list was deleted. */ + ListV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of subscriber lists. */ + ListV1List: { + data: components["schemas"]["ListV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Body for PATCH /api/v1/lists/{id}. */ + ListV1Update: { + /** Format: uuid */ + confirmation_template_id?: string | null; + description?: string | null; + double_opt_in?: boolean; + name?: string; + /** Format: uri */ + redirect_url?: string | null; + }; + /** @description A receiving mailbox on one of the project's verified domains. */ + Mailbox: { /** - * Format: uuid - * @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: email + * @description The full mailbox address, e.g. `support@superbooks.io`. */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + address: string; + /** Format: date-time */ + createdAt: string; + displayName: string | null; /** * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @description The verified domain this mailbox lives on. */ - template_id?: string | null; + domainId: string; + /** Format: uuid */ + id: string; + /** @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. */ + quotaBytes: number | null; /** - * @description discriminator enum property added by openapi-typescript + * @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 {string} */ - type: "WAIT_FOR_EVENT"; - config: { - eventName?: string; - timeout?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; + }; + /** @description A mailbox plus its IMAP/SMTP connection settings. */ + MailboxDetail: components["schemas"]["Mailbox"] & { + /** @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. */ + settings: { + imap: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + smtp: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; }; }; - /** @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. */ - WorkflowConditionStepV1: { + /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ + Problem: { + /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ + code: string; + /** @description Explanation specific to this occurrence. */ + detail?: string; + /** @description Field-level failures. Present on 422 `validation_error` responses. */ + errors?: { + code: string; + message: string; + /** @description RFC 6901 JSON Pointer to the offending field. */ + pointer: string; + }[]; + /** @description Request path the failure occurred on. */ + instance?: string; + /** @description Correlation id — quote it in support requests. */ + request_id?: string; + /** @description HTTP status code, repeated in the body. */ + status: number; + /** @description Short, stable summary — the same for every occurrence of a `type`. */ + title: string; /** - * Format: uuid - * @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: uri + * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + */ + type: string; + }; + ProjectRecord: { + billingLimitCampaigns: number | null; + billingLimitInbound: number | null; + billingLimitTransactional: number | null; + billingLimitWorkflows: number | null; + /** + * Format: date-time + * @description ISO 8601 datetime string */ + createdAt: string; + disabled: boolean; + disabledReason: string | null; + /** Format: uuid */ id: string; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + organizationId: string | null; + /** @description Local-part of the sandbox quick-start sender; null until first derived. */ + sandboxHandle: string | null; + sesRegion: string | null; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * Format: date-time + * @description ISO 8601 datetime string */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "CONDITION"; - config: { - /** @enum {string} */ - mode?: "multi"; - field?: string; - /** @enum {string} */ - operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - value?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - branches?: ({ - id: string; - name: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - value?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - })[]; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + updatedAt: string; }; - /** @description Ends the run early and stamps `exit_reason`. */ - WorkflowExitStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description The project the presented credential is scoped to. */ + ProjectV1: { + /** Format: date-time */ + created_at: string; + /** @description A disabled project sends nothing; every send is refused. */ + disabled: boolean; + /** Format: uuid */ id: string; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; + /** @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. */ + sandbox_address: string | null; + /** @description Locked once the first domain is added. */ + ses_region: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + }; + /** @description Delivery outcomes for one recipient domain on one day. */ + RecipientDomainStatsV1: { + bounced: number; + complained: number; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Format: date-time + * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. */ - type: "EXIT"; - config: { - reason?: string; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + computed_at: string; + /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ + day: string; + delivered: number; + /** @description The recipient's domain, lowercased: the part after the `@`. */ + domain: string; + opened: number; + sent: number; + }; + /** @description Cursor-paginated recipient-domain rollup, newest day first. */ + RecipientDomainStatsV1List: { + data: components["schemas"]["RecipientDomainStatsV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact belonging to a segment. */ + SegmentContactV1: { + /** Format: date-time */ + created_at: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: { + [key: string]: unknown; }; + email: string; + /** Format: uuid */ + id: string; + subscribed: boolean; }; - /** @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. */ - WorkflowWebhookStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Cursor-paginated list of the contacts belonging to a segment. */ + SegmentContactV1List: { + data: components["schemas"]["SegmentContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @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). */ + SegmentFilterV1: { + field: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; + /** @enum {string} */ + unit?: "days" | "hours" | "minutes"; + value?: unknown; + }; + /** @description A segment as exposed on the v1 API. */ + SegmentV1: { + condition: components["schemas"]["FilterConditionV1"] | null; + /** Format: date-time */ + created_at: string; + description: string | null; + /** Format: uuid */ id: string; + member_count: number; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "WEBHOOK"; - config: { - /** Format: uri */ - url?: string; - /** @enum {string} */ - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - headers?: { - [key: string]: string; - }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - body?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + track_membership: boolean; + /** @enum {string} */ + type: "DYNAMIC" | "STATIC"; + /** Format: date-time */ + updated_at: string; }; - /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ - WorkflowUpdateContactStepV1: { - /** - * Format: uuid - * @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. - */ - id: string; + /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ + SegmentV1Create: { + condition?: components["schemas"]["FilterConditionV1"]; + description?: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. + * @default false */ - template_id?: string | null; + track_membership: boolean; /** - * @description discriminator enum property added by openapi-typescript + * @default DYNAMIC * @enum {string} */ - type: "UPDATE_CONTACT"; - config: { - updates?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - subscribed?: boolean; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + type: "DYNAMIC" | "STATIC"; }; - /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ - WorkflowSendAtOptimalTimeStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Acknowledgement that a segment was deleted. */ + SegmentV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "SEND_AT_OPTIMAL_TIME"; - config: { - /** Format: uuid */ - templateId?: string; - fallbackHour?: number; - maxDelayHours?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; }; - /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ - WorkflowCloneV1: { - /** @description Name for the copy. Defaults to `Copy of `. */ - name?: string; + /** @description Cursor-paginated list of segments. */ + SegmentV1List: { + data: components["schemas"]["SegmentV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ - WorkflowStateChangeV1: { - workflow: components["schemas"]["WorkflowV1"]; - /** @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). */ - cancelled_executions: number; + /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ + SegmentV1Update: { + condition?: components["schemas"]["FilterConditionV1"]; + description?: string; + name?: string; + track_membership?: boolean; }; - /** @description Receipt for a single transactional send. */ - EmailV1: { - /** - * Format: uuid - * @description The Email row this send created. Quote it in support requests. - */ - id: string; - /** - * @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 {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; - /** - * Format: email - * @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. - */ - from: string; - }; - /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ - SendEmailV1: { - subject?: string; + /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ + SendEmail: { + attachments?: { + content: string; + contentId?: string; + contentType: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + filename: string; + }[]; + bcc?: string[]; body?: string; - /** Format: uuid */ - template?: string; - subscribed?: boolean; - name?: string; + cc?: string[]; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; from?: string | { - name?: string; /** Format: email */ email: string; + name?: string; }; - /** Format: email */ - reply?: string; headers?: { [key: string]: string; }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + name?: string; + /** Format: email */ + reply?: string; + subject?: string; + subscribed?: boolean; + tags?: string[]; + /** Format: uuid */ + template?: string; + to: string | { + /** Format: email */ + email: string; + name?: string; + } | (string | { + /** Format: email */ + email: string; + name?: string; + })[]; + }; + /** @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`. */ + SendEmailData: { + emails: components["schemas"]["SendEmailRecipientResult"][]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ + SendEmailRecipientResult: { + contact: { + /** Format: email */ + email: string; + /** Format: uuid */ + id: string; }; + /** Format: uuid */ + email: string; + }; + /** @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. */ + SendEmailResponse: { + data: components["schemas"]["SendEmailData"]; + /** @enum {boolean} */ + success: true; + }; + /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ + SendEmailV1: { attachments?: { - filename: string; content: string; - contentType: string; contentId?: string; + contentType: string; /** * @default attachment * @enum {string} */ disposition: "attachment" | "inline"; + filename: string; }[]; - tags?: string[]; - cc?: string[]; bcc?: string[]; + body?: string; + cc?: string[]; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + from?: string | { + /** Format: email */ + email: string; + name?: string; + }; + headers?: { + [key: string]: string; + }; + name?: string; + /** Format: email */ + reply?: string; + subject?: string; + subscribed?: boolean; + tags?: string[]; + /** Format: uuid */ + template?: string; /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ to: string | { - name?: string; /** Format: email */ email: string; + name?: string; }; }; - /** @description Receipt for a sandbox test send. */ - EmailTestV1: { - /** - * Format: uuid - * @description The Email row this send created. - */ - id: string; - /** - * @description Delivery status at the moment of the response — `PENDING` for a send still queued. - * @enum {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; - /** - * Format: email - * @description This project's sandbox sender — resolved server-side, never from the body. - */ - from: string; - /** - * @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 {boolean} - */ - sandbox: true; - }; /** @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. */ SendTestEmailV1: { + /** @description HTML body. Merge tags are rendered as on any other send. */ + body: string; + /** @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. */ + from?: string; + subject: string; /** * Format: email * @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. */ to?: string; - subject: string; - /** @description HTML body. Merge tags are rendered as on any other send. */ - body: string; - /** @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. */ - from?: string; - }; - /** @description Cursor-paginated list of contacts. */ - ContactV1List: { - data: components["schemas"]["ContactV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; }; - /** @description A contact as exposed on the v1 API. */ - ContactV1: { + /** + * @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 {string} + */ + SendingStream: "TRANSACTIONAL" | "MARKETING"; + /** @description A reusable fragment of template markup. */ + Snippet: { + /** @description Template markup. Values it interpolates are escaped like any other. */ + body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + description?: string | null; /** Format: uuid */ id: string; - email: string; - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description Body for POST /api/v1/contacts. */ - ContactV1Create: { - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; - /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ - custom_fields?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description The literal identifier a template includes with `{{> name}}`. */ + name: string; + /** Format: uuid */ + projectId: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; }; - /** @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. */ - ContactV1Update: { - subscribed?: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Cursor-paginated list of snippets. */ + SnippetListResponse: { + data: { + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + data: components["schemas"]["Snippet"][]; + hasMore: boolean; + total: number; + }; + /** @enum {boolean} */ + success: true; }; - /** @description Acknowledgement that a contact was deleted. */ - ContactV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Bare success envelope with no payload. */ + SuccessEmpty: { /** @enum {boolean} */ - deleted: true; + success: true; }; - /** @description Body for POST /api/lists/{id}/subscribe. */ - ListSubscribe: { + /** @description A single suppressed-email record. */ + Suppression: { + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; /** Format: email */ email: string; - /** @description Custom fields to upsert onto the contact as part of subscribing. */ - data?: { - [key: string]: unknown; - }; - /** - * @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. - * @default false - */ - allowResubscribe: boolean; - }; - /** @description Body for POST /api/lists/{id}/unsubscribe. */ - ListUnsubscribe: { - /** Format: email */ - email: string; - }; - /** @description Cursor-paginated list of subscriber lists. */ - ListV1List: { - data: components["schemas"]["ListV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description A subscriber list as exposed on the v1 API. */ - ListV1: { /** Format: uuid */ id: string; - name: string; - description: string | null; - double_opt_in: boolean; /** Format: uuid */ - confirmation_template_id: string | null; - redirect_url: string | null; - /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ - member_count: number; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; + projectId: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD"; }; - /** @description Body for POST /api/v1/lists. */ - ListV1Create: { - name: string; - description?: string | null; - /** - * @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. - * @default false - */ - double_opt_in: boolean; - /** Format: uuid */ - confirmation_template_id?: string | null; + /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ + SuppressionCheckResponse: { /** - * Format: uri - * @description Where a confirmed contact is sent after following the confirmation link. + * Format: date-time + * @description ISO 8601 datetime string */ - redirect_url?: string | null; - }; - /** @description Body for PATCH /api/v1/lists/{id}. */ - ListV1Update: { - name?: string; - description?: string | null; - double_opt_in?: boolean; - /** Format: uuid */ - confirmation_template_id?: string | null; - /** Format: uri */ - redirect_url?: string | null; + createdAt?: string; + /** @enum {string} */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + suppressed: boolean; }; - /** @description Acknowledgement that a list was deleted. */ - ListV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Cursor-paginated list of suppressions. */ + SuppressionListResponse: { + cursor?: string | null; + data: components["schemas"]["Suppression"][]; + hasMore?: boolean; + nextCursor?: string | null; /** @enum {boolean} */ - deleted: true; - }; - /** @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. */ - AddDomainBody: { - /** Format: uuid */ - projectId?: string; - domain: string; - /** - * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. - * @enum {string} - */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - stream?: components["schemas"]["SendingStream"]; - /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ - streamDefault?: boolean; - }; - /** - * @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 {string} - */ - SendingStream: "TRANSACTIONAL" | "MARKETING"; - /** @description Body for PATCH /api/domains/{id}. */ - AssignDomainStream: { - /** - * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. - * @enum {string|null} - */ - stream?: "TRANSACTIONAL" | "MARKETING" | null; - /** @description Make this the project's default identity for its stream, demoting whichever held it. */ - streamDefault?: boolean; - /** - * Format: email - * @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. - */ - defaultFromAddress?: string | null; - }; - /** @description Cursor-paginated list of sending domains. */ - DomainV1List: { - data: components["schemas"]["DomainV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + success: true; }; - /** @description A sending domain as exposed on the v1 API. */ - DomainV1: { - /** Format: uuid */ - id: string; - domain: string; - verified: boolean; - region: string | null; - stream: components["schemas"]["SendingStream"] & (string | null); - stream_default: boolean; - default_from_address: string | null; - mail_from_domain: string | null; - /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ - mail_from_domain_status: string | null; - dkim_verified: boolean; + /** @description A suppressed address as exposed on the v1 API. */ + SuppressionV1: { /** Format: date-time */ created_at: string; - /** Format: date-time */ - updated_at: string; + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + source: string; }; - /** @description Body for POST /api/v1/domains. */ - DomainV1Create: { - domain: string; + /** @description Body for POST /api/v1/suppressions. */ + SuppressionV1Create: { + /** Format: email */ + email: string; /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @default MANUAL * @enum {string} */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - stream?: components["schemas"]["SendingStream"] & unknown; - /** @description Make this the project's default identity for `stream`. Requires `stream`. */ - stream_default?: boolean; + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; - /** @description Acknowledgement that a sending domain was removed. */ - DomainV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Acknowledgement that an address was un-suppressed. */ + SuppressionV1Deleted: { /** @enum {boolean} */ deleted: true; + email: string; }; - /** @description Body for POST /api/templates. */ - CreateTemplate: { - name: string; - description?: string; - subject: string; + /** @description Cursor-paginated list of suppressed addresses. */ + SuppressionV1List: { + data: components["schemas"]["SuppressionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A reusable email template. */ + Template: { body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + description?: string | null; + /** @enum {string} */ + emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ from: string; fromName?: string | null; + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + projectId: string; /** Format: email */ replyTo?: string | null; + subject: string; /** - * @default MARKETING - * @enum {string} + * Format: date-time + * @description ISO 8601 datetime string */ - emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - }; - /** @description Body for PATCH /api/templates/{id}. */ - UpdateTemplate: { - name?: string; - description?: string; - subject?: string; - body?: string; - /** Format: email */ - from?: string; - fromName?: string | null; - /** Format: email */ - replyTo?: string | null; - /** @enum {string} */ - emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + updatedAt: string; }; /** @description Cursor-paginated list of templates. */ - TemplateV1List: { - data: components["schemas"]["TemplateV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + TemplateListResponse: { + data: { + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + data: components["schemas"]["Template"][]; + hasMore: boolean; + total: number; + }; + /** @enum {boolean} */ + success: true; }; /** @description An email template as exposed on the v1 API. */ TemplateV1: { - /** Format: uuid */ - id: string; - name: string; - description: string | null; - subject: string; body: string; + /** Format: date-time */ + created_at: string; + description: string | null; + /** @enum {string} */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; from: string; from_name: string | null; + /** Format: uuid */ + id: string; + name: string; reply_to: string | null; - /** @enum {string} */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - version: number; - /** Format: date-time */ - created_at: string; + subject: string; /** Format: date-time */ updated_at: string; + version: number; }; /** @description Body for POST /api/v1/templates. */ TemplateV1Create: { - name: string; - description?: string | null; - subject: string; body: string; + description?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** * Format: email * @description Sender address. Its domain must be verified for this project. */ from: string; from_name?: string | null; + name: string; /** Format: email */ reply_to?: string | null; - /** - * @default MARKETING - * @enum {string} - */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + subject: string; + }; + /** @description Acknowledgement that a template was deleted. */ + TemplateV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of templates. */ + TemplateV1List: { + data: components["schemas"]["TemplateV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; /** @description Body for PATCH /api/v1/templates/{id}. */ TemplateV1Update: { - name?: string; - description?: string | null; - subject?: string; body?: string; + description?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ from?: string; from_name?: string | null; + name?: string; /** Format: email */ reply_to?: string | null; - /** @enum {string} */ - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - }; - /** @description Acknowledgement that a template was deleted. */ - TemplateV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; - }; - /** @description Body for POST /api/snippets. */ - CreateSnippet: { - name: string; - description?: string | null; - body: string; + subject?: string; }; - /** @description Body for PATCH /api/snippets/{id}. */ - UpdateSnippet: { - name?: string; + TopicCreateV1: { + default_opt_in?: boolean; description?: string | null; - body?: string; - }; - /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ - CreateWebhook: { - /** Format: uri */ - url: string; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - }; - /** @description Body for PATCH /api/webhooks/{id}. */ - UpdateWebhook: { - /** Format: uri */ - url?: string; - eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** @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. */ + key: string; + name: string; }; - /** @description Cursor-paginated list of webhook endpoints. */ - WebhookV1List: { - data: components["schemas"]["WebhookV1"][]; + /** @description One page of the subjects this project mails about. */ + TopicListV1: { + data: components["schemas"]["TopicV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @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. */ - WebhookV1: { - /** Format: uuid */ - id: string; - url: string; - event_types: string[]; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description A newly created webhook and its one-time signing secret. */ - WebhookV1Created: { - webhook: components["schemas"]["WebhookV1"]; - /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ - secret: string; - }; - /** @description Body for POST /api/v1/webhooks. */ - WebhookV1Create: { - /** Format: uri */ - url: string; - event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - }; - /** @description Body for PATCH /api/v1/webhooks/{id}. */ - WebhookV1Update: { - /** Format: uri */ - url?: string; - event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; - }; - /** @description Acknowledgement that a webhook was deleted. */ - WebhookV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; - }; - /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ - WebhookV1SecretRotated: { - /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ - secret: string; - /** - * Format: date-time - * @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. - */ - previous_secret_expires_at: string; - }; - CreateApiKeyBody: { - name: string; - /** @enum {string} */ - legacyGrantPreset?: "FULL" | "SENDING_ONLY"; - /** - * @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 {string} - */ - mode?: "LIVE" | "TEST"; - /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ - scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + TopicSubscribeV1: { /** Format: uuid */ - domainId?: string | null; + contact_id: string; + /** @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. */ + subscribed: boolean; }; - /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ - AddSuppression: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** + * @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 {string} + */ + TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; + TopicSubscriptionV1: { + /** @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. */ + confirmation_url: string | null; + /** Format: date-time */ + confirmed_at: string | null; + contact_id: string; + status: components["schemas"]["TopicSubscriptionStatusV1"]; + topic_id: string; }; - /** @description Cursor-paginated list of suppressed addresses. */ - SuppressionV1List: { - data: components["schemas"]["SuppressionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ + TopicUpdateV1: { + archived?: boolean; + default_opt_in?: boolean; + description?: string | null; + name?: string; }; - /** @description A suppressed address as exposed on the v1 API. */ - SuppressionV1: { - email: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - source: string; + /** @description One subject this project mails about. */ + TopicV1: { + /** @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. */ + archived: boolean; /** Format: date-time */ created_at: string; - }; - /** @description Body for POST /api/v1/suppressions. */ - SuppressionV1Create: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; - /** @description Acknowledgement that an address was un-suppressed. */ - SuppressionV1Deleted: { - email: string; - /** @enum {boolean} */ - deleted: true; + /** @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`. */ + default_opt_in: boolean; + description: string | null; + id: string; + /** @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. */ + key: string; + name: string; + /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ + subscribed_count: number; + unsubscribed_count: number; }; /** @description Body for POST /api/track — record a custom event for a contact. */ TrackEvent: { - event: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; /** Format: email */ email: string; + event: string; subscribed?: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { + }; + /** @description Response from POST /api/track. */ + TrackEventResponse: { + data: { + /** Format: uuid */ + contact: string; + /** Format: uuid */ + event: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ + UpdateContactBody: { + customFields?: { [key: string]: unknown; }; + subscribed?: boolean; }; - /** @description Cursor-paginated list of events, newest first. */ - EventV1List: { - data: components["schemas"]["EventV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description Body for PATCH /api/snippets/{id}. */ + UpdateSnippet: { + body?: string; + description?: string | null; + name?: string; }; - /** @description A recorded custom event. */ - EventV1: { + /** @description Body for PATCH /api/templates/{id}. */ + UpdateTemplate: { + body?: string; + description?: string; + /** @enum {string} */ + emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** Format: email */ + from?: string; + fromName?: string | null; + name?: string; + /** Format: email */ + replyTo?: string | null; + subject?: string; + }; + /** @description Body for PATCH /api/webhooks/{id}. */ + UpdateWebhook: { + eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: uri */ + url?: string; + }; + /** @description Current email usage against the limits that are actually enforced. */ + UsageV1: { + daily: { + /** @description Today's sends. Null when the counter could not be read. */ + emails_sent: number | null; + limit: number; + /** @enum {string} */ + trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + }; + monthly: { + categories: { + campaign: { + emails_sent: number; + limit: number | null; + }; + inbound: { + emails_sent: number; + limit: number | null; + }; + transactional: { + emails_sent: number; + limit: number | null; + }; + workflow: { + emails_sent: number; + limit: number | null; + }; + }; + emails_sent: number; + /** @description Monthly cap on the total. Null when per-category limits govern instead. */ + limit: number | null; + }; + /** + * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. + * @enum {string} + */ + plan: "free" | "pro" | "custom"; + }; + /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ + VerifyEmail: { + /** Format: email */ + email: string; + }; + /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ + VerifyEmailResponse: { + data: { + /** Format: email */ + email: string; + reason?: string; + valid: boolean; + } & { + [key: string]: unknown; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description A user-managed outbound webhook. */ + Webhook: { + consecutiveFailures: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + disabledAt?: string | null; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; /** Format: uuid */ id: string; - name: string; + lastFour?: string; /** Format: uuid */ - contact_id: string | null; + projectId: string; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + /** Format: uri */ + url: string; + }; + /** @description An attempted webhook delivery. */ + WebhookCall: { + attempt: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + eventType: string; /** Format: uuid */ - email_id: string | null; - /** @description The payload recorded with the event, or null. */ + id: string; payload: { [key: string]: unknown; - } | null; - /** Format: date-time */ - created_at: string; + }; + responseBody?: string | null; + responseStatus?: number | null; + /** @enum {string} */ + status: "PENDING" | "SUCCESS" | "FAILED"; + /** Format: uuid */ + webhookId: string; }; - /** @description Body for POST /api/v1/events. */ - EventTrackV1: { - /** @description Event name, e.g. `user.signup`. */ - name: string; - /** - * Format: uuid - * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. - */ - contact_id?: string; - /** @description Arbitrary event payload. */ - payload?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Cursor-paginated list of recent calls for a single webhook. */ + WebhookCallsListResponse: { + cursor?: string | null; + data: components["schemas"]["WebhookCall"][]; + hasMore?: boolean; + nextCursor?: string | null; + /** @enum {boolean} */ + success: true; + }; + /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ + WebhookCreateResponse: { + /** @description A user-managed outbound webhook. */ + data: components["schemas"]["Webhook"] & { + /** @description Plaintext shared secret. Returned ONCE on create. */ + secret: string; }; + /** @enum {boolean} */ + success: true; }; - /** @description Every distinct event name in the project, most frequent first. */ - EventNamesV1: { - data: string[]; + /** @description Single webhook (no secret). */ + WebhookGetResponse: { + data: components["schemas"]["Webhook"]; + /** @enum {boolean} */ + success: true; }; - /** @description Per-name event counts over the applied window. */ - EventStatsV1: { + /** @description List of webhooks for the auth'd project. */ + WebhookListResponse: { + data: components["schemas"]["Webhook"][]; + /** @enum {boolean} */ + success: true; + }; + /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ + WebhookRotateSecretResponse: { data: { - name: string; - count: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** Format: uuid */ + id: string; + /** @description New plaintext shared secret. */ + secret: string; + }; + /** @enum {boolean} */ + success: true; }; - /** @description The time range this response was computed over, after the 90-day clamp. */ - AnalyticsWindowV1: { + /** @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. */ + WebhookV1: { /** Format: date-time */ - from: string; + created_at: string; + event_types: string[]; + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; /** Format: date-time */ - to: string; + updated_at: string; + url: string; }; - /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ - AnalyticsTimeseriesV1: { - data: { - /** Format: date-time */ - date: string; - emails: number; - delivered: number; - opens: number; - clicks: number; - bounces: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description Body for POST /api/v1/webhooks. */ + WebhookV1Create: { + event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** Format: uri */ + url: string; }; - /** @description Campaign counters and engagement over the window. */ - AnalyticsCampaignStatsV1: { - total: number; - /** @description Campaigns in DRAFT or SCHEDULED. */ - active: number; - completed: number; - /** @description Percentage, one decimal place. */ - average_open_rate: number; - average_click_rate: number; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description A newly created webhook and its one-time signing secret. */ + WebhookV1Created: { + /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + webhook: components["schemas"]["WebhookV1"]; }; - /** @description Sent campaigns ranked by open rate. */ - AnalyticsTopCampaignsV1: { - data: { - /** Format: uuid */ - id: string; - subject: string; - sent: number; - opened: number; - clicked: number; - open_rate: number; - click_rate: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description Acknowledgement that a webhook was deleted. */ + WebhookV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; }; - /** @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. */ - DeliverabilityDiagnosisV1: { - domain: string; - address: string | null; - /** Format: date-time */ - checked_at: string; - identity: components["schemas"]["DeliverabilityIdentityV1"]; - suppression: components["schemas"]["DeliverabilitySuppressionV1"]; - recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; - /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ - findings: components["schemas"]["DeliverabilityFindingV1"][]; + /** @description Cursor-paginated list of webhook endpoints. */ + WebhookV1List: { + data: components["schemas"]["WebhookV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description The sending identity's DNS health, as last refreshed. */ - DeliverabilityIdentityV1: { - /** @description Whether this project has a domain record at all. False makes every other field null. */ - registered: boolean; - verified: boolean; + /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ + WebhookV1SecretRotated: { /** - * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. - * @enum {string|null} + * Format: date-time + * @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. */ - dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + previous_secret_expires_at: string; + /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + }; + /** @description Body for PATCH /api/v1/webhooks/{id}. */ + WebhookV1Update: { + event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: uri */ + url?: string; + }; + /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ + WorkflowCloneV1: { + /** @description Name for the copy. Defaults to `Copy of `. */ + name?: string; + }; + /** @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. */ + WorkflowConditionStepV1: { + config: { + branches?: ({ + id: string; + name: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + })[]; + field?: string; + /** @enum {string} */ + mode?: "multi"; + /** @enum {string} */ + operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * @description SPF alignment for the sending identity. - * @enum {string|null} + * Format: uuid + * @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. */ - spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * @description The DMARC policy published at `_dmarc.`. - * @enum {string|null} + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + template_id?: string | null; /** - * @description Inbound receiving only. Null unless the domain has receiving enabled. - * @enum {string|null} + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; - mail_from_domain: string | null; - /** @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. */ - mail_from_domain_status: string | null; + type: "CONDITION"; + }; + /** @description Body for POST /api/v1/workflows. */ + WorkflowCreateV1: { + allow_reentry?: boolean; + description?: string; + /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ + enabled?: boolean; + /** @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. */ + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + name: string; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; + }; + /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ + WorkflowDelayStepV1: { + config: { + amount?: number; + /** @enum {string} */ + unit?: "minutes" | "hours" | "days"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * Format: date-time - * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. + * Format: uuid + * @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. */ - last_checked_at: string | null; - }; - /** @description Null unless the request named an `address`. */ - DeliverabilitySuppressionV1: { - suppressed: boolean; - /** @enum {string|null} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; - /** @enum {string|null} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; - /** Format: date-time */ - suppressed_at: string | null; - } | null; - /** @description Delivery outcomes over the requested window. */ - DeliverabilityRecentDeliveryV1: { - window_days: number; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * @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 {string} + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - scope: "project"; - sent: number; - delivered: number; - bounced: number; - complained: number; - failed: number; - /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ - bounce_rate: number | null; - complaint_rate: number | null; - }; - /** @description One diagnosed problem, with its fix. */ - DeliverabilityFindingV1: { - /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ - code: string; - severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; - /** @description What is wrong, in one sentence. */ - summary: string; - /** @description What to do about it. */ - remedy: string; - }; - /** - * @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 {string} - */ - DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; - /** @description Cursor-paginated recipient-domain rollup, newest day first. */ - RecipientDomainStatsV1List: { - data: components["schemas"]["RecipientDomainStatsV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description Delivery outcomes for one recipient domain on one day. */ - RecipientDomainStatsV1: { - /** @description The recipient's domain, lowercased: the part after the `@`. */ - domain: string; - /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ - day: string; - sent: number; - delivered: number; - bounced: number; - complained: number; - opened: number; + template_id?: string | null; /** - * Format: date-time - * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - computed_at: string; - }; - /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ - DmarcReportV1List: { - data: components["schemas"]["DmarcReportV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + type: "DELAY"; }; - /** @description One DMARC aggregate (RUA) report. */ - DmarcReportV1: { + /** @description Confirmation that a workflow was deleted. */ + WorkflowDeletedV1: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ id: string; - /** @description The receiver's own id for this report. */ - report_id: string; - /** @description The reporting receiver, e.g. `google.com`. */ - org_name: string; - /** @description The domain of yours the report is about. */ - policy_domain: string; - /** Format: date-time */ - range_begin: string; - /** Format: date-time */ - range_end: string; - total_count: number; - /** @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. */ - pass_count: number; - fail_count: number; - /** @description Per-sending-source rows, as the receiver reported them. */ - sources: { - source_ip: string; - count: number; - disposition: string; - dkim: string; - spf: string; - header_from: string; - }[]; - /** Format: date-time */ - received_at: string; }; - /** @description Current email usage against the limits that are actually enforced. */ - UsageV1: { + /** @description Body for POST /api/v1/workflows/{id}/executions. */ + WorkflowExecutionStartV1: { /** - * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. - * @enum {string} + * Format: uuid + * @description Contact to enter the workflow. Must belong to this project. */ - plan: "free" | "pro" | "custom"; - monthly: { - emails_sent: number; - /** @description Monthly cap on the total. Null when per-category limits govern instead. */ - limit: number | null; - categories: { - transactional: { - emails_sent: number; - limit: number | null; - }; - campaign: { - emails_sent: number; - limit: number | null; - }; - workflow: { - emails_sent: number; - limit: number | null; - }; - inbound: { - emails_sent: number; - limit: number | null; - }; - }; - }; - daily: { - /** @description Today's sends. Null when the counter could not be read. */ - emails_sent: number | null; - limit: number; - /** @enum {string} */ - trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + contact_id: string; + /** @description Extra variables merged into the contact's data for this run. */ + context?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; }; - /** @description The project the presented credential is scoped to. */ - ProjectV1: { + /** @description One contact's run through a workflow. */ + WorkflowExecutionV1: { + /** Format: date-time */ + completed_at: string | null; + /** Format: uuid */ + contact_id: string; + /** Format: uuid */ + current_step_id: string | null; + exit_reason: string | null; /** Format: uuid */ id: string; - name: string; - /** @description A disabled project sends nothing; every send is refused. */ - disabled: boolean; - /** @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. */ - sandbox_address: string | null; - /** @description Locked once the first domain is added. */ - ses_region: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; /** Format: date-time */ - created_at: string; - }; - /** @description A receiving mailbox on one of the project's verified domains. */ - Mailbox: { + started_at: string; + /** @enum {string} */ + status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; /** Format: uuid */ + workflow_id: string; + }; + /** @description Cursor-paginated list of workflow executions, newest first. */ + WorkflowExecutionV1List: { + data: components["schemas"]["WorkflowExecutionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Ends the run early and stamps `exit_reason`. */ + WorkflowExitStepV1: { + config: { + reason?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: email - * @description The full mailbox address, e.g. `support@superbooks.io`. + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - address: string; - displayName: string | null; + template_id?: string | null; /** - * @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 discriminator enum property added by openapi-typescript * @enum {string} */ - status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; - /** @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. */ - quotaBytes: number | null; + type: "EXIT"; + }; + /** @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. */ + WorkflowGraphReplaceV1: { + /** @description The complete step set. Exactly one must be a `TRIGGER`. */ + steps: components["schemas"]["WorkflowStepV1"][]; + /** @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. */ + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @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. */ + WorkflowGraphV1: { + steps: components["schemas"]["WorkflowStepReadV1"][]; + transitions: components["schemas"]["WorkflowTransitionV1"][]; + /** @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. */ + version: number; + /** Format: uuid */ + workflow_id: string; + }; + /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ + WorkflowSendAtOptimalTimeStepV1: { + config: { + fallbackHour?: number; + maxDelayHours?: number; + /** Format: uuid */ + templateId?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** * Format: uuid - * @description The verified domain this mailbox lives on. + * @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. */ - domainId: string; - /** Format: date-time */ - createdAt: string; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_AT_OPTIMAL_TIME"; }; - /** @description A mailbox plus its IMAP/SMTP connection settings. */ - MailboxDetail: components["schemas"]["Mailbox"] & { - /** @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. */ - settings: { - imap: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; - }; - smtp: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ + WorkflowSendEmailStepV1: { + config: { + body?: string; + recipient?: { + /** Format: email */ + customEmail?: string; + /** @enum {string} */ + type: "CONTACT" | "CUSTOM"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; + subject?: string; + /** Format: uuid */ + templateId?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; - }; - CreateMailboxBody: { /** * Format: uuid - * @description Defaults to the project the credential resolves to. Naming a different one is refused. + * @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. */ - projectId?: string; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** * Format: uuid - * @description A VERIFIED domain belonging to this project. + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - domainId: string; - /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ - localPart: string; - displayName?: string; - /** @description NOT IMPLEMENTED — sending any value answers 400. */ - quotaBytes?: number; + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_EMAIL"; }; - /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ - DraftMailboxMessage: { - /** @enum {string} */ - mode: "draft" | "rewrite" | "subject"; - brief?: string; - draft?: string; - instruction?: string; - /** @enum {string} */ - tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; - recipientContext?: string; - senderAddress?: string; + /** + * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @enum {string} + */ + WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @description One step of a linear workflow sequence. */ + WorkflowSequenceStepV1: { + /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description Human-readable label, e.g. `Day 0: welcome`. */ + name: string; + /** + * Format: uuid + * @description For `SEND_EMAIL`: a template in this project. + */ + template_id?: string; + type: components["schemas"]["WorkflowSequenceStepTypeV1"]; }; - /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ - ComposeMailboxMessage: { - to: string[]; - cc?: string[]; - bcc?: string[]; - subject: string; - body: string; + /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ + WorkflowStateChangeV1: { + /** @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). */ + cancelled_executions: number; + workflow: components["schemas"]["WorkflowV1"]; + }; + /** @description Execution, email and conversion totals for one workflow. */ + WorkflowStatsV1: { + avg_duration_ms: number | null; + /** @description Execution counts keyed by status; a status with no executions is absent. */ + by_status: { + [key: string]: number; + }; + /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ + completion_rate: number | null; + conversions: { + count: number; + event_name: string; + /** Format: uuid */ + goal_id: string; + name: string; + }[]; + emails: { + clicked: number; + opened: number; + sent: number; + }; + enabled: boolean; + name: string; + /** @description Steps in the workflow's graph, trigger step included. */ + step_count: number; + total: number; + trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + /** Format: uuid */ + workflow_id: string; + }; + /** @description Where this step sits on the editor canvas. */ + WorkflowStepPositionV1: { + x: number; + y: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; - /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ - AppPassword: { - /** Format: uuid */ + /** @description One node of a workflow graph, as read. */ + WorkflowStepReadV1: { + /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; - /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ name: string; - /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ - scopes: ("imap" | "smtp")[]; - /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ - lastFour: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: date-time - * @description Null until a mail client has authenticated with it at least once. + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - lastUsedAt: string | null; - /** Format: date-time */ - createdAt: string; + template_id?: string | null; + /** @enum {string} */ + type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; }; - /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ - AppPasswordReveal: { + /** @description One node of a workflow graph. */ + WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; + /** @description One directed edge between two steps. */ + WorkflowTransitionV1: { + /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ + condition: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; /** Format: uuid */ - id: string; - /** - * Format: uri - * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. - */ - revealUrl: string; + from_step_id: string; /** - * Format: date-time - * @description When the link stops working. Five minutes after creation; the password itself does not expire. + * Format: uuid + * @description Caller-chosen on a write, exactly like a step id. */ - revealExpiresAt: string; + id: string; + /** @description Evaluation order among the edges leaving one step; lowest first. */ + priority: number; + /** Format: uuid */ + to_step_id: string; }; - /** @description Body for POST /api/mailboxes/:id/app-passwords. */ - CreateAppPassword: { - name: string; + /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ + WorkflowTriggerStepV1: { + config: { + eventName?: string; + intervalMs?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * @default [ - * "imap", - * "smtp" - * ] + * Format: uuid + * @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. */ - scopes: ("imap" | "smtp")[]; - }; - /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ - VerifyEmail: { - /** Format: email */ - email: string; - }; - TopicListV1: { - data: components["schemas"]["TopicV1"][]; - cursor: string | null; - has_more: boolean; - }; - /** @description One subject this project mails about. */ - TopicV1: { id: string; - /** @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. */ - key: string; - name: string; - description: string | null; - /** @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`. */ - default_opt_in: boolean; - /** @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. */ - archived: boolean; - /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ - subscribed_count: number; - unsubscribed_count: number; - /** Format: date-time */ - created_at: string; - }; - TopicCreateV1: { - /** @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. */ - key: string; name: string; - description?: string | null; - default_opt_in?: boolean; - }; - /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ - TopicUpdateV1: { - name?: string; - description?: string | null; - default_opt_in?: boolean; - archived?: boolean; - }; - TopicSubscriptionV1: { - topic_id: string; - contact_id: string; - status: components["schemas"]["TopicSubscriptionStatusV1"]; - /** Format: date-time */ - confirmed_at: string | null; - /** @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. */ - confirmation_url: string | null; - }; - /** - * @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 {string} - */ - TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; - TopicSubscribeV1: { - /** Format: uuid */ - contact_id: string; - /** @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. */ - subscribed: boolean; - }; - /** @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. */ - ContactTopicPreferencesV1: { - contact_id: string; - /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ - subscribed: boolean; - topics: { - topic_id: string; - /** @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. */ - key: string; - name: string; - /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ - subscribed: boolean; - pending: boolean; - }[]; - }; - /** @description One verdict per address, in the order they were given. */ - EmailValidationBatchV1: { - results: components["schemas"]["EmailValidationV1"][]; - }; - /** @description One address's verdict, with the evidence behind it. */ - EmailValidationV1: { - email: string; - verdict: components["schemas"]["EmailValidationVerdictV1"]; - /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ - is_disposable: boolean; - /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ - is_role_address: boolean; - /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ - is_personal: boolean; - /** @description The domain publishes MX records. */ - has_mx_records: boolean; - /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ - reasons: string[]; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "TRIGGER"; }; /** - * @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`. + * @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 {string} */ - EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; - EmailValidationBatchRequestV1: { - /** @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. */ - emails: string[]; - }; - /** @description One bulk validation run over a list. */ - EmailValidationRunV1: { + WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ + WorkflowUpdateContactStepV1: { + config: { + subscribed?: boolean; + updates?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; - list_id: string | null; - /** @enum {string} */ - status: "pending" | "running" | "completed" | "failed"; - /** @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. */ - processed_count: number; - deliverable_count: number; - undeliverable_count: number; - risky_count: number; - /** Format: date-time */ - started_at: string | null; - /** Format: date-time */ - completed_at: string | null; - /** @description Set only on `failed`. Prose for an operator; never parse it. */ - failure_reason: string | null; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "UPDATE_CONTACT"; + }; + /** @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. */ + WorkflowUpdateV1: { + allow_reentry?: boolean; + description?: string; + enabled?: boolean; + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + /** @description Per-workflow start rate cap. `null` removes the cap. */ + max_executions_per_hour?: number | null; + name?: string; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + }; + /** @description An automation workflow as exposed on the v1 API. */ + WorkflowV1: { + allow_reentry: boolean; /** Format: date-time */ created_at: string; + description: string | null; + enabled: boolean; + /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ + event_name: string | null; + /** Format: uuid */ + id: string; + max_executions_per_hour: number | null; + name: string; + /** @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. */ + step_count: number; + /** @enum {string} */ + trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; + /** Format: date-time */ + updated_at: string; + /** @description Incremented on every structural (step/transition) change. */ + version: number; }; - /** @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. */ - EmailValidationResultListV1: { - data: (components["schemas"]["EmailValidationV1"] & { - contact_id: string | null; - })[]; - /** @description Pass as `cursor` for the next page; null on the last. */ - cursor: string | null; + /** @description Cursor-paginated list of workflows. */ + WorkflowV1List: { + data: components["schemas"]["WorkflowV1"][]; has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ + WorkflowWaitForEventStepV1: { + config: { + eventName?: string; + timeout?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WAIT_FOR_EVENT"; + }; + /** @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. */ + WorkflowWebhookStepV1: { + config: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + body?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + headers?: { + [key: string]: string; + }; + /** @enum {string} */ + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Format: uri */ + url?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WEBHOOK"; }; }; responses: never; @@ -5235,12 +5252,13 @@ interface components { pathItems: never; } interface operations { - v1ListCampaigns: { + listContacts: { parameters: { query?: { limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + cursor?: string; + search?: string; + subscribed?: "true" | "false"; }; header?: never; path?: never; @@ -5248,2822 +5266,2855 @@ interface operations { }; requestBody?: never; responses: { - /** @description Campaign list */ + /** @description Contact list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1List"]; + "application/json": components["schemas"]["ContactListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateCampaign: { + createContact: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Create"]; + "application/json": components["schemas"]["CreateContact"]; }; }; responses: { - /** @description Campaign created */ + /** @description Contact created */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `internal_error`. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - }; - }; - v1GetCampaign: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The campaign */ - 200: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Email already exists for this project */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteCampaign: { + bulkCreateContacts: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkCreateBody"]; + }; + }; responses: { - /** @description Campaign deleted */ + /** @description Bulk-create result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Deleted"]; + "application/json": { + data: { + created: number; + errors: { + index: number; + message: string; + }[]; + skipped: number; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateCampaign: { + bulkDeleteContacts: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Update"]; + "application/json": components["schemas"]["ContactBulkDeleteBody"]; }; }; responses: { - /** @description The updated campaign */ + /** @description Bulk-delete result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: { + deleted: number; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendCampaign: { + upsertContact: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path: { - /** @description Resource id. */ - id: string; - }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Send"]; + "application/json": components["schemas"]["CreateContact"]; }; }; responses: { - /** @description The campaign, now `SENDING` or `SCHEDULED` */ + /** @description Contact created or updated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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 failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelCampaign: { + getContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The cancelled campaign */ + /** @description Contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1PauseCampaign: { + deleteContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The paused campaign */ + /** @description Contact deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["IdResponse"]; }; }; - /** @description `validation_error` — only a `SENDING` campaign can be paused. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ResumeCampaign: { + updateContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateContactBody"]; + }; + }; responses: { - /** @description The resumed campaign */ + /** @description Updated contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetCampaignStats: { + listDomains: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description Domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Stats"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["DomainListResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListCampaignFailures: { + addDomain: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AddDomainBody"]; + }; + }; responses: { - /** @description Failed sends */ - 200: { + /** @description Domain added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1FailureList"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @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. */ + 502: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1RetryCampaignFailures: { + getDomain: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The retry was queued */ + /** @description Domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1RetryFailed"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a retry is already running for this campaign. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegments: { + deleteDomain: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment list */ + /** @description Domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1List"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateSegment: { + assignDomainStream: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SegmentV1Create"]; + "application/json": components["schemas"]["AssignDomainStream"]; }; }; responses: { - /** @description Segment created */ - 201: { + /** @description Updated sending identity */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetSegment: { + startDomainSetup: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The segment */ + /** @description Guided setup session */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: { + /** + * Format: uri + * @description Open this in a browser to publish the records. Short-lived and domain-specific. + */ + connectUrl: string; + /** @description When `connectUrl` stops working. */ + expiresAt: string; + token: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteSegment: { + getDomainVerification: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment deleted */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1Deleted"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["DomainVerificationStatus"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the segment is still used by one or more active campaigns. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateSegment: { + verifyDomain: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SegmentV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated segment */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: components["schemas"]["DomainVerificationStatus"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegmentContacts: { + listEmails: { parameters: { query?: { limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + cursor?: string; + tag?: string; + /** @description Delivery lifecycle of the message. Engagement is reported separately. */ + status?: components["schemas"]["EmailDeliveryStatus"]; + from?: string; }; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment member list */ + /** @description Email list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentContactV1List"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["EmailListResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflows: { + sendEmail: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; }; - header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmail"]; + }; + }; responses: { - /** @description Workflow list */ + /** @description Email accepted / sent */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1List"]; + "application/json": components["schemas"]["SendEmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateWorkflow: { + sendEmailBatch: { parameters: { query?: never; - header?: never; + header?: { + "Idempotency-Key"?: string; + }; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowCreateV1"]; + "application/json": components["schemas"]["BatchSendBody"]; }; }; responses: { - /** @description Workflow created */ - 201: { + /** @description All entries sent */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["BatchSendResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Partial success — at least one entry failed */ + 207: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflow: { + getEmail: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow */ + /** @description Email and its delivery history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["EmailDetailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteWorkflow: { + cancelScheduledEmail: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow deleted */ + /** @description Email cancelled */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowDeletedV1"]; + "application/json": components["schemas"]["EmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow still has running executions. */ - 409: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Email already past PENDING */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateWorkflow: { + subscribeToList: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description List id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowUpdateV1"]; + "application/json": components["schemas"]["ListSubscribe"]; }; }; responses: { - /** @description Updated workflow */ + /** @description Contact subscribed, or an existing membership returned unchanged */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["ListSubscribeResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the trigger cannot be changed while executions are running. */ + /** @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. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflowExecutions: { + unsubscribeFromList: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only executions in this state. */ - status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description List id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ListUnsubscribe"]; + }; + }; responses: { - /** @description Execution list */ + /** @description Contact unsubscribed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1List"]; + "application/json": components["schemas"]["ListUnsubscribeResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1StartWorkflowExecution: { + listMailboxes: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WorkflowExecutionStartV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Execution started */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Mailbox list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["Mailbox"][]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelWorkflowExecution: { + createMailbox: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow execution id. */ - execution_id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMailboxBody"]; + }; + }; responses: { - /** @description Cancelled execution */ - 200: { + /** @description Mailbox provisioned */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": { + data: components["schemas"]["Mailbox"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no execution with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflowStats: { + getMailbox: { parameters: { - query?: { - /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ - from?: string | null; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow statistics */ + /** @description Mailbox with connection settings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStatsV1"]; + "application/json": { + data: components["schemas"]["MailboxDetail"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflowGraph: { + deleteMailbox: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The workflow's graph */ + /** @description Mailbox deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowGraphV1"]; + "application/json": { + data: { + /** @enum {boolean} */ + deleted: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ReplaceWorkflowGraph: { + listAppPasswords: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; - }; - }; + requestBody?: never; responses: { - /** @description The graph as it now stands */ + /** @description App password list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowGraphV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["AppPassword"][]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CloneWorkflow: { + createAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["WorkflowCloneV1"]; + "application/json": components["schemas"]["CreateAppPassword"]; }; }; responses: { - /** @description The cloned workflow */ + /** @description App password created; the secret is behind the one-time link */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + data: components["schemas"]["AppPasswordReveal"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1PauseWorkflow: { + revokeAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; + passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The workflow, and the number of runs this call cancelled */ + /** @description App password revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStateChangeV1"]; + "application/json": { + data: { + /** @enum {boolean} */ + revoked: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ResumeWorkflow: { + draftMailboxMessage: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DraftMailboxMessage"]; + }; + }; responses: { - /** @description The workflow, with `cancelled_executions` always 0 */ + /** @description A draft. Nothing was sent. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStateChangeV1"]; + "application/json": { + data: { + /** @description Suggested plain-text body, or null. */ + body: string | null; + /** + * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. + * @enum {boolean} + */ + sent: false; + /** @description Suggested subject, or null. */ + subject: string | null; + /** @description Alternative subject lines (`subject` mode); empty otherwise. */ + subjects: string[]; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @description The drafting model was unreachable or returned nothing usable. */ + 502: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendEmail: { + sendMailboxMessage: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; + header?: never; + path: { + id: string; }; - path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SendEmailV1"]; + "application/json": components["schemas"]["ComposeMailboxMessage"]; }; }; responses: { - /** @description Email queued */ - 202: { + /** @description Message submitted */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailV1"]; + "application/json": { + data: { + /** + * Format: uuid + * @description The conversation this send started. Replies thread onto it. + */ + conversationId: string; + /** + * Format: uuid + * @description The stored outbound message. + */ + messageId: string; + /** @enum {boolean} */ + submitted: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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 The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + /** @description The mail server refused the submission. Nothing was sent. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ 503: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendTestEmail: { + listApiKeys: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SendTestEmailV1"]; + path: { + /** @description Project id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Test email queued */ - 202: { + /** @description API key list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailTestV1"]; + "application/json": components["schemas"]["ApiKeyListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - listEmails: { + createApiKey: { parameters: { - query?: { - limit?: number; - cursor?: string; - tag?: string; - /** @description Delivery lifecycle of the message. Engagement is reported separately. */ - status?: components["schemas"]["EmailDeliveryStatus"]; - from?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateApiKeyBody"]; + }; + }; responses: { - /** @description Email list */ - 200: { + /** @description API key created; the secret is behind the reveal link. */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailListResponse"]; + "application/json": { + /** @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. */ + data: components["schemas"]["ApiKey"] & { + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8113,32 +8164,30 @@ interface operations { }; }; }; - sendEmail: { + revokeApiKey: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; + header?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; }; - path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendEmail"]; - }; - }; + requestBody?: never; responses: { - /** @description Email accepted / sent */ + /** @description API key revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SendEmailResponse"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -8156,7 +8205,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -8165,17 +8214,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8201,35 +8241,45 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - getEmail: { + rotateApiKey: { parameters: { query?: never; header?: never; path: { + /** @description Project id. */ id: string; + /** @description API key id. */ + keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Email and its delivery history */ + /** @description API key rotated; the new secret is behind the reveal link. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailDetailResponse"]; + "application/json": { + data: { + lastFour: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8288,40 +8338,29 @@ interface operations { }; }; }; - sendEmailBatch: { + listSnippets: { parameters: { - query?: never; - header?: { - "Idempotency-Key"?: string; + query?: { + limit?: number; + cursor?: string; + search?: string; }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["BatchSendBody"]; - }; - }; + requestBody?: never; responses: { - /** @description All entries sent */ + /** @description Snippet list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BatchSendResponse"]; - }; - }; - /** @description Partial success — at least one entry failed */ - 207: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BatchSendResponse"]; + "application/json": components["schemas"]["SnippetListResponse"]; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -8339,7 +8378,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -8348,16 +8387,7 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; @@ -8384,35 +8414,32 @@ interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - cancelScheduledEmail: { + createSnippet: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSnippet"]; + }; + }; responses: { - /** @description Email cancelled */ - 200: { + /** @description Snippet created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailResponse"]; + "application/json": { + data: components["schemas"]["Snippet"]; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8442,8 +8469,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description A snippet with that name already exists in this project */ + 409: { headers: { [name: string]: unknown; }; @@ -8451,8 +8478,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already past PENDING */ - 409: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -8480,27 +8507,28 @@ interface operations { }; }; }; - listContacts: { + getSnippet: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - subscribed?: "true" | "false"; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact list */ + /** @description Snippet */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactListResponse"]; + "application/json": { + data: components["schemas"]["Snippet"]; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8530,8 +8558,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8559,30 +8587,24 @@ interface operations { }; }; }; - createContact: { + deleteSnippet: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateContact"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Contact created */ - 201: { + /** @description Snippet deleted */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -8612,17 +8634,8 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already exists for this project */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8650,29 +8663,31 @@ interface operations { }; }; }; - upsertContact: { + updateSnippet: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateContact"]; + "application/json": components["schemas"]["UpdateSnippet"]; }; }; responses: { - /** @description Contact created or updated */ + /** @description Updated snippet */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { + data: components["schemas"]["Snippet"]; /** @enum {boolean} */ success: true; - data: components["schemas"]["Contact"]; }; }; }; @@ -8703,6 +8718,24 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -8732,37 +8765,26 @@ interface operations { }; }; }; - bulkCreateContacts: { + listSuppressions: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactBulkCreateBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Bulk-create result */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - created: number; - skipped: number; - errors: { - index: number; - message: string; - }[]; - }; - }; + "application/json": components["schemas"]["SuppressionListResponse"]; }; }; /** @description Validation error */ @@ -8792,15 +8814,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8821,7 +8834,7 @@ interface operations { }; }; }; - bulkDeleteContacts: { + addSuppression: { parameters: { query?: never; header?: never; @@ -8830,23 +8843,17 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContactBulkDeleteBody"]; + "application/json": components["schemas"]["AddSuppression"]; }; }; responses: { - /** @description Bulk-delete result */ - 200: { + /** @description Suppression added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - deleted: number; - }; - }; + "application/json": components["schemas"]["Suppression"]; }; }; /** @description Validation error */ @@ -8876,15 +8883,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8905,28 +8903,25 @@ interface operations { }; }; }; - getContact: { + checkSuppression: { parameters: { query?: never; header?: never; path: { - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact */ + /** @description Suppression check result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["SuppressionCheckResponse"]; }; }; /** @description Validation error */ @@ -8956,15 +8951,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8985,25 +8971,24 @@ interface operations { }; }; }; - deleteContact: { + removeSuppression: { parameters: { query?: never; header?: never; path: { - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact deleted */ - 200: { + /** @description Suppression removed */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["IdResponse"]; - }; + content?: never; }; /** @description Validation error */ 400: { @@ -9032,15 +9017,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -9061,32 +9037,27 @@ interface operations { }; }; }; - updateContact: { + listTemplates: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + cursor?: string; + search?: string; + emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateContactBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated contact */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["TemplateListResponse"]; }; }; /** @description Validation error */ @@ -9116,15 +9087,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -9154,415 +9116,442 @@ interface operations { }; }; }; - v1ListContacts: { + createTemplate: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Case-insensitive substring match on the email address. */ - search?: string; - /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ - subscribed?: "true" | "false"; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTemplate"]; + }; + }; responses: { - /** @description Contact list */ - 200: { + /** @description Template created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1List"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateContact: { + getTemplate: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactV1Create"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The created contact */ - 201: { + /** @description Template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a contact with this email already exists in this project. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetContact: { + deleteTemplate: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The contact */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; + "application/json": components["schemas"]["IdResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Template still in use */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteContact: { + updateTemplate: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTemplate"]; + }; + }; responses: { - /** @description Contact deleted */ + /** @description Updated template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1Deleted"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateContact: { + trackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ContactV1Update"]; + "application/json": components["schemas"]["TrackEvent"]; }; }; responses: { - /** @description The updated contact */ + /** @description Event tracked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["TrackEventResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - subscribeToList: { + createProject: { parameters: { query?: never; header?: never; - path: { - /** @description List id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ListSubscribe"]; + "application/json": { + name: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; + }; }; }; responses: { - /** @description Contact subscribed, or an existing membership returned unchanged */ - 200: { + /** @description Project created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSubscribeResponse"]; + "application/json": components["schemas"]["ProjectRecord"]; }; }; /** @description Validation error */ @@ -9592,24 +9581,6 @@ interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -9639,102 +9610,83 @@ interface operations { }; }; }; - unsubscribeFromList: { + v1GetCampaignAnalytics: { parameters: { - query?: never; - header?: never; - path: { - /** @description List id. */ - id: string; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListUnsubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact unsubscribed */ + /** @description Campaign statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUnsubscribeResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListLists: { + v1GetAnalyticsTimeseries: { parameters: { query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; header?: never; path?: never; @@ -9742,13 +9694,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Subscriber lists */ + /** @description Daily time series */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1List"]; + "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9798,26 +9750,28 @@ interface operations { }; }; }; - v1CreateList: { + v1ListTopCampaigns: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + limit?: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The created list */ - 201: { + /** @description Ranked campaigns */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9867,25 +9821,26 @@ interface operations { }; }; }; - v1GetList: { + v1ListCampaigns: { parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The list */ + /** @description Campaign list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["CampaignV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9906,15 +9861,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9944,25 +9890,29 @@ interface operations { }; }; }; - v1DeleteList: { + v1CreateCampaign: { parameters: { query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Create"]; + }; + }; responses: { - /** @description List deleted */ - 200: { + /** @description Campaign created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1Deleted"]; + "application/json": components["schemas"]["CampaignV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9983,7 +9933,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ 404: { headers: { [name: string]: unknown; @@ -9992,7 +9942,16 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; @@ -10021,7 +9980,7 @@ interface operations { }; }; }; - v1UpdateList: { + v1GetCampaign: { parameters: { query?: never; header?: never; @@ -10031,19 +9990,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated list */ + /** @description The campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["CampaignV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10064,7 +10019,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -10102,662 +10057,737 @@ interface operations { }; }; }; - listDomains: { + v1DeleteCampaign: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain list */ + /** @description Campaign deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainListResponse"]; + "application/json": components["schemas"]["CampaignV1Deleted"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - addDomain: { + v1UpdateCampaign: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AddDomainBody"]; + "application/json": components["schemas"]["CampaignV1Update"]; }; }; responses: { - /** @description Domain added */ - 201: { + /** @description The updated campaign */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ - 502: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomain: { + v1CancelCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain */ + /** @description The cancelled campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteDomain: { + v1ListCampaignFailures: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain removed */ + /** @description Failed sends */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["CampaignV1FailureList"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - assignDomainStream: { + v1PauseCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AssignDomainStream"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated sending identity */ + /** @description The paused campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `SENDING` campaign can be paused. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomainVerification: { + v1ResumeCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The resumed campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["DomainVerificationStatus"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - verifyDomain: { + v1RetryCampaignFailures: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The retry was queued */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["DomainVerificationStatus"]; - }; + "application/json": components["schemas"]["CampaignV1RetryFailed"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — a retry is already running for this campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - startDomainSetup: { + v1SendCampaign: { parameters: { query?: never; - header?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CampaignV1Send"]; + }; + }; responses: { - /** @description Guided setup session */ + /** @description The campaign, now `SENDING` or `SCHEDULED` */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - token: string; - /** - * Format: uri - * @description Open this in a browser to publish the records. Short-lived and domain-specific. - */ - connectUrl: string; - /** @description When `connectUrl` stops working. */ - expiresAt: string; - }; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @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. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListDomains: { + v1GetCampaignStats: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Sending domain list */ + /** @description Campaign statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1List"]; + "application/json": components["schemas"]["CampaignV1Stats"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10778,6 +10808,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -10807,26 +10846,30 @@ interface operations { }; }; }; - v1CreateDomain: { + v1ListContacts: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the email address. */ + search?: string; + /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ + subscribed?: "true" | "false"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["DomainV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The registered sending domain, awaiting DNS */ - 201: { + /** @description Contact list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10837,18 +10880,9 @@ interface operations { content: { "application/problem+json": components["schemas"]["Problem"]; }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — this domain is already registered to a project you can send from. */ - 409: { + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -10883,36 +10917,28 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1GetDomain: { + v1CreateContact: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Create"]; + }; + }; responses: { - /** @description The sending domain */ - 200: { + /** @description The created contact */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10933,8 +10959,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ - 404: { + /** @description `conflict` — a contact with this email already exists in this project. */ + 409: { headers: { [name: string]: unknown; }; @@ -10971,7 +10997,7 @@ interface operations { }; }; }; - v1DeleteDomain: { + v1GetContact: { parameters: { query?: never; header?: never; @@ -10983,13 +11009,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Sending domain removed */ + /** @description The contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1Deleted"]; + "application/json": components["schemas"]["ContactV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11010,7 +11036,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11019,15 +11045,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -11057,7 +11074,7 @@ interface operations { }; }; }; - v1VerifyDomain: { + v1DeleteContact: { parameters: { query?: never; header?: never; @@ -11069,13 +11086,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description The sending domain, as SES and DNS now report it */ + /** @description Contact deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11096,7 +11113,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11134,434 +11151,379 @@ interface operations { }; }; }; - listTemplates: { + v1UpdateContact: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Update"]; + }; + }; responses: { - /** @description Template list */ + /** @description The updated contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateListResponse"]; + "application/json": components["schemas"]["ContactV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createTemplate: { + v1GetContactTopicPreferences: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateTemplate"]; + path: { + /** @description The contact. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Template created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description The contact's preferences */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ContactTopicPreferencesV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getTemplate: { + v1DiagnoseDeliverability: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query: { + /** @description A sending domain in this project, e.g. `example.com`. */ + domain: 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. */ + address?: string; + /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ + window_days?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Template */ + /** @description The diagnosis, with findings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteTemplate: { + v1ListDmarcReports: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ + days?: number; + /** @description Restrict to reports about one of your domains. */ + domain?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Template deleted */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["IdResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description Cursor-paginated DMARC aggregate reports */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DmarcReportV1List"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Template still in use */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateTemplate: { + v1ListRecipientDomainStats: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ + days?: number; + /** @description Restrict to one recipient domain. */ + domain?: string; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateTemplate"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated template */ + /** @description Cursor-paginated recipient-domain rollup */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["RecipientDomainStatsV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListTemplates: { + v1ListDomains: { parameters: { query?: { limit?: number; /** @description Opaque cursor from a previous response's `next_cursor`. */ after?: string; - /** @description Case-insensitive substring match on the name. */ - search?: string; - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; }; header?: never; path?: never; @@ -11569,13 +11531,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Template list */ + /** @description Sending domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1List"]; + "application/json": components["schemas"]["DomainV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11625,7 +11587,7 @@ interface operations { }; }; }; - v1CreateTemplate: { + v1CreateDomain: { parameters: { query?: never; header?: never; @@ -11634,17 +11596,17 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TemplateV1Create"]; + "application/json": components["schemas"]["DomainV1Create"]; }; }; responses: { - /** @description The created template */ + /** @description The registered sending domain, awaiting DNS */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11665,6 +11627,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `conflict` — this domain is already registered to a project you can send from. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -11692,9 +11663,18 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @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. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; }; - v1GetTemplate: { + v1GetDomain: { parameters: { query?: never; header?: never; @@ -11706,13 +11686,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description The template */ + /** @description The sending domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11733,7 +11713,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11771,7 +11751,7 @@ interface operations { }; }; }; - v1DeleteTemplate: { + v1DeleteDomain: { parameters: { query?: never; header?: never; @@ -11783,13 +11763,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Template deleted */ + /** @description Sending domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1Deleted"]; + "application/json": components["schemas"]["DomainV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11810,7 +11790,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11819,7 +11799,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ 409: { headers: { [name: string]: unknown; @@ -11857,7 +11837,7 @@ interface operations { }; }; }; - v1UpdateTemplate: { + v1VerifyDomain: { parameters: { query?: never; header?: never; @@ -11867,19 +11847,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TemplateV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated template */ + /** @description The sending domain, as SES and DNS now report it */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11900,7 +11876,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11938,85 +11914,175 @@ interface operations { }; }; }; - listSnippets: { + v1ValidateEmails: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + }; + }; responses: { - /** @description Snippet list */ + /** @description One verdict per address, in the order they were given */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SnippetListResponse"]; + "application/json": components["schemas"]["EmailValidationBatchV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmailV1"]; + }; + }; + responses: { + /** @description Email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation error */ - 400: { + /** @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. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ + /** @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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createSnippet: { + v1SendTestEmail: { parameters: { query?: never; header?: never; @@ -12025,887 +12091,825 @@ interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateSnippet"]; + "application/json": components["schemas"]["SendTestEmailV1"]; }; }; responses: { - /** @description Snippet created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description Test email queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailTestV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @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: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description A snippet with that name already exists in this project */ + /** @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. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ + /** @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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getSnippet: { + v1ListEvents: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only events with this exact name. */ + event_name?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Snippet */ + /** @description Event list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EventV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteSnippet: { + v1TrackEvent: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EventTrackV1"]; + }; + }; responses: { - /** @description Snippet deleted */ - 200: { + /** @description Event recorded */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; + "application/json": components["schemas"]["EventV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no contact with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateSnippet: { + v1ListEventNames: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateSnippet"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated snippet */ + /** @description Event names */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Unauthorized — missing or invalid auth */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EventNamesV1"]; }; - }; - /** @description Resource not found */ - 404: { + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description A snippet with that name already exists in this project */ - 409: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhooks: { + v1GetEventStats: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook list */ + /** @description Event counts */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookListResponse"]; + "application/json": components["schemas"]["EventStatsV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createWebhook: { + v1ListLists: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook created */ - 201: { + /** @description Subscriber lists */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCreateResponse"]; + "application/json": components["schemas"]["ListV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getWebhook: { + v1CreateList: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Webhook */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Create"]; }; - /** @description Validation error */ - 400: { + }; + responses: { + /** @description The created list */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteWebhook: { + v1GetList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook deleted */ + /** @description The list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateWebhook: { + v1DeleteList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook updated */ + /** @description List deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookGetResponse"]; + "application/json": components["schemas"]["ListV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateWebhookSecret: { + v1UpdateList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Update"]; + }; + }; responses: { - /** @description Secret rotated */ + /** @description The updated list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookRotateSecretResponse"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhookCalls: { + v1StartListValidationRun: { parameters: { - query?: { - limit?: number; - cursor?: string; - }; + query?: never; header?: never; path: { + /** @description The list to validate. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook call history */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookCallsListResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The run, accepted and queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListWebhooks: { + v1GetProject: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook list */ + /** @description The authenticated project */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1List"]; + "application/json": components["schemas"]["ProjectV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -12926,6 +12930,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — the project was deleted between authentication and this read. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -12955,26 +12968,26 @@ interface operations { }; }; }; - v1CreateWebhook: { + v1ListSegments: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The created webhook and its one-time signing secret */ - 201: { + /** @description Segment list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1Created"]; + "application/json": components["schemas"]["SegmentV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13024,29 +13037,30 @@ interface operations { }; }; }; - v1GetWebhook: { + v1CreateSegment: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Create"]; + }; + }; responses: { - /** @description The webhook */ - 200: { + /** @description Segment created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + 400: { headers: { [name: string]: unknown; }; @@ -13054,8 +13068,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; @@ -13063,8 +13077,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -13101,7 +13115,7 @@ interface operations { }; }; }; - v1DeleteWebhook: { + v1GetSegment: { parameters: { query?: never; header?: never; @@ -13113,13 +13127,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Webhook deleted */ + /** @description The segment */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1Deleted"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13140,7 +13154,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13178,7 +13192,7 @@ interface operations { }; }; }; - v1UpdateWebhook: { + v1DeleteSegment: { parameters: { query?: never; header?: never; @@ -13188,19 +13202,15 @@ interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated webhook */ + /** @description Segment deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1"]; + "application/json": components["schemas"]["SegmentV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13221,7 +13231,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13230,6 +13240,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `conflict` — the segment is still used by one or more active campaigns. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -13259,7 +13278,7 @@ interface operations { }; }; }; - v1RotateWebhookSecret: { + v1UpdateSegment: { parameters: { query?: never; header?: never; @@ -13269,15 +13288,19 @@ interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Update"]; + }; + }; responses: { - /** @description The new signing secret and the moment the previous one stops verifying */ + /** @description The updated segment */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1SecretRotated"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13298,7 +13321,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13336,639 +13359,609 @@ interface operations { }; }; }; - listApiKeys: { + v1ListSegmentContacts: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path: { - /** @description Project id. */ + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key list */ + /** @description Segment member list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiKeyListResponse"]; + "application/json": components["schemas"]["SegmentContactV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createApiKey: { + v1ListSuppressions: { parameters: { - query?: never; - header?: never; - path: { - /** @description Project id. */ - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateApiKeyBody"]; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Filter to one reason. Omit for every suppressed address. */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { - /** @description API key created; the secret is behind the reveal link. */ - 201: { + /** @description Suppression list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - /** @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. */ - data: components["schemas"]["ApiKey"] & { - /** - * Format: uri - * @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. - */ - revealUrl: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - }; - }; + "application/json": components["schemas"]["SuppressionV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateApiKey: { + v1CreateSuppression: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - /** @description API key id. */ - keyId: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description API key rotated; the new secret is behind the reveal link. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - lastFour: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - }; - }; - }; + requestBody: { + content: { + "application/json": components["schemas"]["SuppressionV1Create"]; }; - /** @description Validation error */ - 400: { + }; + responses: { + /** @description The suppressed address */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - revokeApiKey: { + v1GetSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ - id: string; - /** @description API key id. */ - keyId: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key revoked */ + /** @description The suppression record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listSuppressions: { + v1DeleteSuppression: { parameters: { - query?: { - limit?: number; - cursor?: string; - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description The suppressed address, URL-encoded. */ + email: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression list */ + /** @description Address removed from the suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionListResponse"]; + "application/json": components["schemas"]["SuppressionV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - addSuppression: { + v1ListTemplates: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the name. */ + search?: string; + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AddSuppression"]; - }; - }; + requestBody?: never; responses: { - /** @description Suppression added */ - 201: { + /** @description Template list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Suppression"]; + "application/json": components["schemas"]["TemplateV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - checkSuppression: { + v1CreateTemplate: { parameters: { query?: never; header?: never; - path: { - /** @description URL-encoded email address */ - email: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateV1Create"]; + }; + }; responses: { - /** @description Suppression check result */ - 200: { + /** @description The created template */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionCheckResponse"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - removeSuppression: { + v1GetTemplate: { parameters: { query?: never; header?: never; path: { - /** @description URL-encoded email address */ - email: string; + /** @description Resource id. */ + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression removed */ - 204: { + /** @description The template */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["TemplateV1"]; + }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListSuppressions: { + v1DeleteTemplate: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Filter to one reason. Omit for every suppressed address. */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression list */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1List"]; + "application/json": components["schemas"]["TemplateV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13989,6 +13982,24 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14018,26 +14029,29 @@ interface operations { }; }; }; - v1CreateSuppression: { + v1UpdateTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SuppressionV1Create"]; + "application/json": components["schemas"]["TemplateV1Update"]; }; }; responses: { - /** @description The suppressed address */ - 201: { + /** @description The updated template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14058,6 +14072,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14087,25 +14110,27 @@ interface operations { }; }; }; - v1GetSuppression: { + v1ListTopics: { parameters: { - query?: never; - header?: never; - path: { - /** @description The suppressed address, URL-encoded. */ - email: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + include_archived?: boolean | null; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The suppression record */ + /** @description One page of topics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1"]; + "application/json": components["schemas"]["TopicListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14126,15 +14151,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14164,25 +14180,26 @@ interface operations { }; }; }; - v1DeleteSuppression: { + v1CreateTopic: { parameters: { query?: never; header?: never; - path: { - /** @description The suppressed address, URL-encoded. */ - email: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicCreateV1"]; + }; + }; responses: { - /** @description Address removed from the suppression list */ - 200: { + /** @description The created topic */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1Deleted"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14232,97 +14249,97 @@ interface operations { }; }; }; - trackEvent: { + v1GetTopic: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TrackEvent"]; + path: { + /** @description The topic. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Event tracked */ + /** @description The topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TrackEventResponse"]; + "application/json": components["schemas"]["TopicV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListEvents: { + v1UpdateTopic: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only events with this exact name. */ - event_name?: string; + query?: never; + header?: never; + path: { + /** @description The topic. */ + id: string; }; - header?: never; - path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicUpdateV1"]; + }; + }; responses: { - /** @description Event list */ + /** @description The updated topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1List"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14372,26 +14389,29 @@ interface operations { }; }; }; - v1TrackEvent: { + v1SetTopicSubscription: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["EventTrackV1"]; + "application/json": components["schemas"]["TopicSubscribeV1"]; }; }; responses: { - /** @description Event recorded */ - 201: { + /** @description The resulting subscription */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1"]; + "application/json": components["schemas"]["TopicSubscriptionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14412,15 +14432,6 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no contact with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14450,7 +14461,7 @@ interface operations { }; }; }; - v1ListEventNames: { + v1GetUsage: { parameters: { query?: never; header?: never; @@ -14459,13 +14470,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Event names */ + /** @description Current usage */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventNamesV1"]; + "application/json": components["schemas"]["UsageV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14515,27 +14526,25 @@ interface operations { }; }; }; - v1GetEventStats: { + v1GetValidationRun: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description The validation run. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Event counts */ + /** @description The run */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStatsV1"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14585,27 +14594,31 @@ interface operations { }; }; }; - v1GetAnalyticsTimeseries: { + v1ListValidationRunResults: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ + verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; }; header?: never; - path?: never; + path: { + /** @description The validation run. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Daily time series */ + /** @description One page of results */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; + "application/json": components["schemas"]["EmailValidationResultListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14655,13 +14668,12 @@ interface operations { }; }; }; - v1GetCampaignAnalytics: { + v1ListWebhooks: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; header?: never; path?: never; @@ -14669,13 +14681,13 @@ interface operations { }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description Webhook list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; + "application/json": components["schemas"]["WebhookV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14725,28 +14737,26 @@ interface operations { }; }; }; - v1ListTopCampaigns: { + v1CreateWebhook: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - limit?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Create"]; + }; + }; responses: { - /** @description Ranked campaigns */ - 200: { + /** @description The created webhook and its one-time signing secret */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; + "application/json": components["schemas"]["WebhookV1Created"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14796,29 +14806,25 @@ interface operations { }; }; }; - v1DiagnoseDeliverability: { + v1GetWebhook: { parameters: { - query: { - /** @description A sending domain in this project, e.g. `example.com`. */ - domain: 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. */ - address?: string; - /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ - window_days?: number; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description The diagnosis, with findings */ + /** @description The webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14839,6 +14845,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14868,30 +14883,25 @@ interface operations { }; }; }; - v1ListRecipientDomainStats: { + v1DeleteWebhook: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ - days?: number; - /** @description Restrict to one recipient domain. */ - domain?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Cursor-paginated recipient-domain rollup */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecipientDomainStatsV1List"]; + "application/json": components["schemas"]["WebhookV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14912,6 +14922,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14941,30 +14960,29 @@ interface operations { }; }; }; - v1ListDmarcReports: { + v1UpdateWebhook: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ - days?: number; - /** @description Restrict to reports about one of your domains. */ - domain?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Update"]; + }; + }; responses: { - /** @description Cursor-paginated DMARC aggregate reports */ + /** @description The updated webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DmarcReportV1List"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14985,6 +15003,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15014,22 +15041,25 @@ interface operations { }; }; }; - v1GetUsage: { + v1RotateWebhookSecret: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Current usage */ + /** @description The new signing secret and the moment the previous one stops verifying */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UsageV1"]; + "application/json": components["schemas"]["WebhookV1SecretRotated"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -15050,6 +15080,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15079,35 +15118,30 @@ interface operations { }; }; }; - v1GetProject: { + v1ListWorkflows: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The authenticated project */ + /** @description Workflow list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["WorkflowV1List"]; }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; @@ -15115,8 +15149,8 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — the project was deleted between authentication and this read. */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -15153,962 +15187,845 @@ interface operations { }; }; }; - listMailboxes: { + v1CreateWorkflow: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowCreateV1"]; + }; + }; responses: { - /** @description Mailbox list */ - 200: { + /** @description Workflow created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Mailbox"][]; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createMailbox: { + v1CancelWorkflowExecution: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateMailboxBody"]; + path: { + /** @description Workflow execution id. */ + execution_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Mailbox provisioned */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Mailbox"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description Cancelled execution */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowExecutionV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no execution with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getMailbox: { + v1GetWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox with connection settings */ + /** @description Workflow */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["MailboxDetail"]; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteMailbox: { + v1DeleteWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox deleted */ + /** @description Workflow deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - deleted: true; - }; - }; + "application/json": components["schemas"]["WorkflowDeletedV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the workflow still has running executions. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - draftMailboxMessage: { + v1UpdateWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["DraftMailboxMessage"]; + "application/json": components["schemas"]["WorkflowUpdateV1"]; }; }; responses: { - /** @description A draft. Nothing was sent. */ + /** @description Updated workflow */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @description Suggested subject, or null. */ - subject: string | null; - /** @description Suggested plain-text body, or null. */ - body: string | null; - /** @description Alternative subject lines (`subject` mode); empty otherwise. */ - subjects: string[]; - /** - * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. - * @enum {boolean} - */ - sent: false; - }; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; - }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the trigger cannot be changed while executions are running. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The drafting model was unreachable or returned nothing usable. */ - 502: { + /** @description `internal_error`. */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - sendMailboxMessage: { + v1CloneWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["ComposeMailboxMessage"]; + "application/json": components["schemas"]["WorkflowCloneV1"]; }; }; responses: { - /** @description Message submitted */ + /** @description The cloned workflow */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - submitted: true; - /** - * Format: uuid - * @description The conversation this send started. Replies thread onto it. - */ - conversationId: string; - /** - * Format: uuid - * @description The stored outbound message. - */ - messageId: string; - }; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description The mail server refused the submission. Nothing was sent. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listAppPasswords: { + v1ListWorkflowExecutions: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only executions in this state. */ + status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + }; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description App password list */ + /** @description Execution list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["AppPassword"][]; - }; + "application/json": components["schemas"]["WorkflowExecutionV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createAppPassword: { + v1StartWorkflowExecution: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateAppPassword"]; + "application/json": components["schemas"]["WorkflowExecutionStartV1"]; }; }; responses: { - /** @description App password created; the secret is behind the one-time link */ + /** @description Execution started */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["AppPasswordReveal"]; - }; + "application/json": components["schemas"]["WorkflowExecutionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - revokeAppPassword: { + v1GetWorkflowGraph: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; - passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description App password revoked */ + /** @description The workflow's graph */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - revoked: true; - }; - }; + "application/json": components["schemas"]["WorkflowGraphV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createProject: { + v1ReplaceWorkflowGraph: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Workflow id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": { - name: string; - /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. - * @enum {string} - */ - sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; + "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; }; }; responses: { - /** @description Project created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProjectRecord"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The graph as it now stands */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowGraphV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - }; - verifyEmailAddress: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["VerifyEmail"]; - }; - }; - responses: { - /** @description Verification result */ - 200: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VerifyEmailResponse"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation error */ - 400: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListTopics: { + v1PauseWorkflow: { parameters: { - query?: { - cursor?: string; - limit?: number; - include_archived?: boolean | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Workflow id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description One page of topics */ + /** @description The workflow, and the number of runs this call cancelled */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicListV1"]; + "application/json": components["schemas"]["WorkflowStateChangeV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16129,6 +16046,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16158,26 +16084,25 @@ interface operations { }; }; }; - v1CreateTopic: { + v1ResumeWorkflow: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TopicCreateV1"]; + path: { + /** @description Workflow id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The created topic */ - 201: { + /** @description The workflow, with `cancelled_executions` always 0 */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["WorkflowStateChangeV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16198,6 +16123,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16227,25 +16161,28 @@ interface operations { }; }; }; - v1GetTopic: { + v1GetWorkflowStats: { parameters: { - query?: never; + query?: { + /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ + from?: string | null; + }; header?: never; path: { - /** @description The topic. */ + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The topic */ + /** @description Workflow statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["WorkflowStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16266,6 +16203,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16295,492 +16241,565 @@ interface operations { }; }; }; - v1UpdateTopic: { + verifyEmailAddress: { parameters: { query?: never; header?: never; - path: { - /** @description The topic. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["TopicUpdateV1"]; + "application/json": components["schemas"]["VerifyEmail"]; }; }; responses: { - /** @description The updated topic */ + /** @description Verification result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["VerifyEmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + }; + }; + listWebhooks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["WebhookListResponse"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SetTopicSubscription: { + createWebhook: { parameters: { - query?: never; - header?: never; - path: { - /** @description The topic. */ - id: string; - }; + query?: never; + header?: never; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["TopicSubscribeV1"]; + "application/json": components["schemas"]["CreateWebhook"]; }; }; responses: { - /** @description The resulting subscription */ - 200: { + /** @description Webhook created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicSubscriptionV1"]; + "application/json": components["schemas"]["WebhookCreateResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetContactTopicPreferences: { + getWebhook: { parameters: { query?: never; header?: never; path: { - /** @description The contact. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The contact's preferences */ + /** @description Webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactTopicPreferencesV1"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ValidateEmails: { + deleteWebhook: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description One verdict per address, in the order they were given */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationBatchV1"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1StartListValidationRun: { + updateWebhook: { parameters: { query?: never; header?: never; path: { - /** @description The list to validate. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebhook"]; + }; + }; responses: { - /** @description The run, accepted and queued */ - 202: { + /** @description Webhook updated */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationRunV1"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetValidationRun: { + listWebhookCalls: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + }; header?: never; path: { - /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The run */ + /** @description Webhook call history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationRunV1"]; + "application/json": components["schemas"]["WebhookCallsListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListValidationRunResults: { + rotateWebhookSecret: { parameters: { - query?: { - cursor?: string; - limit?: number; - /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ - verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; - }; + query?: never; header?: never; path: { - /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description One page of results */ + /** @description Secret rotated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationResultListV1"]; + "application/json": components["schemas"]["WebhookRotateSecretResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; @@ -17929,16 +17948,18 @@ declare class TopicsResource { * is no delete — archiving is the retire button, because a topic is where * people's answers are recorded. {@link listAll} drives the loop for you. * - * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + * Paginated on `limit` + `after`, like every other v1 collection. */ list(query?: ListTopicsV1Query): Promise; /** * Iterate every topic across pages, yielding one topic at a time. * - * The walk is written out here rather than delegated to `paginateCursor` - * because this endpoint names its cursor `cursor` on both sides — the query - * parameter and the response field — where every other v1 list takes `after` - * and answers `next_cursor`. + * This used to be written out by hand: the endpoint named its cursor `cursor` + * on both sides where every other v1 list takes `after` and answers + * `next_cursor`, so the shared walker sent a parameter the route ignored and + * read a field it never returned — which silently re-fetched page one until + * `has_more` happened to be false. The route speaks the one dialect now, so + * this delegates like every other collection. */ listAll(query?: ListTopicsV1Query): AsyncGenerator; /** @@ -18038,19 +18059,17 @@ declare class ValidationResource { * 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. * - * This list pages on `cursor`, not the `after` every other v1 collection - * takes, and its envelope carries the next page under `cursor` rather than - * `next_cursor`. {@link listResultsAll} drives that loop for you. + * Pages on `after` and answers `next_cursor`, like every other v1 + * collection. {@link listResultsAll} drives that loop for you. */ listResults(id: string, query?: ListValidationResultsV1Query): Promise; /** * Iterate every result across pages, yielding one address's verdict at a time. * - * Hand-rolled rather than routed through `paginateCursor`: the shared helper - * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on - * both sides, so the helper would send an ignored parameter and re-fetch page - * one forever. Stops on `has_more: false`, a null cursor, or a cursor the - * server repeats. + * This was hand-rolled through 1.0, because the endpoint spoke `cursor` on + * both sides while the shared helper 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. */ listResultsAll(id: string, query?: ListValidationResultsV1Query): AsyncGenerator; } diff --git a/dist/index.js b/dist/index.js index 195e500..2c42ed9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1433,7 +1433,7 @@ var TopicsResource = class { * is no delete — archiving is the retire button, because a topic is where * people's answers are recorded. {@link listAll} drives the loop for you. * - * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + * Paginated on `limit` + `after`, like every other v1 collection. */ async list(query) { return this.client.request({ @@ -1445,22 +1445,15 @@ var TopicsResource = class { /** * Iterate every topic across pages, yielding one topic at a time. * - * The walk is written out here rather than delegated to `paginateCursor` - * because this endpoint names its cursor `cursor` on both sides — the query - * parameter and the response field — where every other v1 list takes `after` - * and answers `next_cursor`. + * This used to be written out by hand: the endpoint named its cursor `cursor` + * on both sides where every other v1 list takes `after` and answers + * `next_cursor`, so the shared walker sent a parameter the route ignored and + * read a field it never returned — which silently re-fetched page one until + * `has_more` happened to be false. The route speaks the one dialect now, so + * this delegates like every other collection. */ async *listAll(query) { - let cursor = query?.cursor; - for (; ; ) { - const page = await this.list({ ...query, cursor }); - for (const topic of page.data) { - yield topic; - } - const next = page.cursor; - if (!page.has_more || next === null || next === cursor) return; - cursor = next; - } + yield* paginateCursor((after) => this.list({ ...query, after }), query?.after); } /** * Create a topic. @@ -1593,9 +1586,8 @@ var ValidationResource = class { * 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. * - * This list pages on `cursor`, not the `after` every other v1 collection - * takes, and its envelope carries the next page under `cursor` rather than - * `next_cursor`. {@link listResultsAll} drives that loop for you. + * Pages on `after` and answers `next_cursor`, like every other v1 + * collection. {@link listResultsAll} drives that loop for you. */ async listResults(id, query) { return this.client.request({ @@ -1607,23 +1599,13 @@ var ValidationResource = class { /** * Iterate every result across pages, yielding one address's verdict at a time. * - * Hand-rolled rather than routed through `paginateCursor`: the shared helper - * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on - * both sides, so the helper would send an ignored parameter and re-fetch page - * one forever. Stops on `has_more: false`, a null cursor, or a cursor the - * server repeats. + * This was hand-rolled through 1.0, because the endpoint spoke `cursor` on + * both sides while the shared helper 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. */ async *listResultsAll(id, query) { - let cursor = query?.cursor; - for (; ; ) { - const page = await this.listResults(id, { ...query, cursor }); - for (const result of page.data ?? []) { - yield result; - } - const next = page.cursor; - if (!page.has_more || next === null || next === void 0 || next === cursor) return; - cursor = next; - } + yield* paginateCursor((after) => this.listResults(id, { ...query, after }), query?.after); } }; diff --git a/openapi.json b/openapi.json index 0f3a394..4379966 100644 --- a/openapi.json +++ b/openapi.json @@ -1,3070 +1,2872 @@ { - "openapi": "3.1.0", - "info": { - "title": "Sendly API", - "version": "1.0.0", - "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.", - "contact": { - "name": "Sendly Support", - "url": "https://sendly.now" - }, - "license": { - "name": "AGPL-3.0", - "url": "https://www.gnu.org/licenses/agpl-3.0.txt" - } - }, - "servers": [ - { - "url": "https://api.sendly.now" - } - ], - "tags": [ - { - "name": "Emails", - "description": "Send transactional email and inspect deliveries." - }, - { - "name": "Campaigns", - "description": "Bulk sends to a list, segment, or filtered audience. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." - }, - { - "name": "Segments", - "description": "Saved audiences — a `DYNAMIC` filter re-evaluated on read, or a `STATIC` membership list. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." - }, - { - "name": "Contacts", - "description": "Manage subscribers and per-contact custom data." - }, - { - "name": "Lists", - "description": "Self-serve list membership. These two endpoints accept sending-only keys so they can back a public subscribe/unsubscribe form; list management itself is dashboard-only." - }, - { - "name": "Domains", - "description": "Register sending domains and manage SES verification." - }, - { - "name": "Templates", - "description": "Reusable email templates referenced by send operations." - }, - { - "name": "Webhooks", - "description": "User-managed outbound webhooks for email and contact events." - }, - { - "name": "API Keys", - "description": "Project-scoped sending/API credentials. All four operations are documented; minting and rotation answer with a one-time reveal link rather than the secret, so a delegated caller never receives a key it could use." - }, - { - "name": "Suppression", - "description": "Project-scoped suppression list. Hard bounces and complaints land here automatically." - }, - { - "name": "Workflows", - "description": "Event-triggered automations and the contact runs through them. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors)." - }, - { - "name": "Events", - "description": "Track custom contact events from your application." - }, - { - "name": "Analytics", - "description": "Aggregate sending and engagement metrics. Every read is bounded to a window of at most 90 days and cached for 15 minutes." - }, - { - "name": "Deliverability", - "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": "Usage", - "description": "Current email usage against the monthly and daily limits the platform enforces." - }, - { - "name": "Projects", - "description": "The project a credential is scoped to, and creating new ones. Configuring an existing project stays a dashboard action." - }, - { - "name": "Mailboxes", - "description": "Real receiving mailboxes on your verified domains. Creating one changes how that domain's mail is routed; the messages a mailbox receives are not part of this API." - }, - { - "name": "Verify", - "description": "Open email-validation endpoint (no auth required). Used by the marketing-site verifier." - }, - { - "name": "Validation", - "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": "Topics", - "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." - } - ], "components": { - "securitySchemes": { - "ApiKeyAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "API Key", - "description": "API key authentication. Use a `sk_*` (FULL) or `pk_*` (SENDING_ONLY) key as the bearer token. Public keys (`pk_*`) are restricted to the email-send endpoints and the self-serve list subscribe/unsubscribe pair; every other endpoint — event tracking included — answers 403 for them. In scope terms (used by `/api/v1/*` operations): FULL keys hold every scope; SENDING_ONLY keys hold only `emails:send`." - }, - "SessionAuth": { - "type": "apiKey", - "in": "cookie", - "name": "better-auth.session_token", - "description": "BetterAuth session cookie. Used by the dashboard / browser clients. When present, the active project is taken from the `x-project-id` header." - }, - "OAuth2": { - "type": "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", - "tokenUrl": "https://app.sendly.now/api/auth/oauth2/token", - "scopes": { - "emails:send": "Send emails from your verified domains", - "emails:read": "View the emails you have sent and their delivery status", - "contacts:read": "View your contacts and their custom fields", - "contacts:write": "Create, update, and delete your contacts", - "campaigns:read": "View your campaigns and their performance", - "campaigns:write": "Create, edit, and organize your campaigns", - "segments:read": "View your segments and who belongs to them", - "segments:write": "Create, edit, and delete your segments", - "workflows:read": "View your automation workflows and their runs", - "workflows:write": "Create, edit, enable, and delete your automation workflows", - "templates:read": "View your email templates", - "templates:write": "Create, edit, and delete your email templates", - "domains:read": "View your sending domains and their verification status", - "domains:write": "Add and remove sending domains, and trigger verification", - "webhooks:read": "View your webhook endpoints and their delivery history", - "webhooks:write": "Create, edit, and delete your webhook endpoints", - "suppression:read": "View the addresses on your suppression list", - "suppression:write": "Add and remove addresses on your suppression list", - "analytics:read": "View your sending analytics and engagement metrics", - "usage:read": "View your usage totals and billing limits", - "events:read": "View the custom events your application has recorded", - "events:write": "Record custom events for your contacts", - "projects:read": "View your projects and their settings", - "projects:write": "Create new projects on your account", - "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:send": "Send or schedule your campaigns to their audience", - "mailboxes:read": "View the mailboxes on your domains and their settings", - "mailboxes:write": "Create and delete mailboxes on your verified domains", - "emails:test": "Send test emails to your own address from the Sendly sandbox", - "deliverability:read": "Check why mail from one of your domains is not arriving", - "mailboxes:send": "Write and send new email from your hosted mailboxes, as that address", - "validation:read": "View your email validation runs and their results", - "validation:write": "Check whether email addresses can receive mail — this is billed per address", - "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", - "lists:read": "View your subscriber lists and who is on them", - "lists:write": "Create, rename, and delete your subscriber lists" - } - } - } - } - }, + "parameters": {}, "schemas": { - "Error": { - "type": "object", + "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. `stream` assigns the identity to transactional or marketing traffic at creation; omit it to leave the identity serving both.", "properties": { - "success": { - "type": "boolean", + "domain": { + "maxLength": 253, + "minLength": 3, + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "region": { + "description": "Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region.", "enum": [ - false - ] + "us-east-1", + "us-west-2", + "eu-west-1" + ], + "type": "string" }, - "error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "code": { - "type": "string" - }, - "details": { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": {} - } - }, - "required": [ - "errors" - ] - } - }, - "required": [ - "message", - "code" - ] + "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": [ - "error" + "domain" ], - "description": "Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`." + "type": "object" }, - "Problem": { - "type": "object", + "AddSuppression": { + "description": "Body for POST /api/suppression — manually add an email to the suppression list.", "properties": { - "type": { - "type": "string", - "format": "uri", - "description": "Dereferenceable URI identifying the error class, anchored on the docs errors page." - }, - "title": { - "type": "string", - "description": "Short, stable summary — the same for every occurrence of a `type`." + "email": { + "format": "email", + "type": "string" }, - "status": { - "type": "integer", - "description": "HTTP status code, repeated in the body." + "reason": { + "default": "MANUAL", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "AnalyticsCampaignStatsV1": { + "description": "Campaign counters and engagement over the window.", + "properties": { + "active": { + "description": "Campaigns in DRAFT or SCHEDULED.", + "type": "integer" }, - "detail": { - "type": "string", - "description": "Explanation specific to this occurrence." + "average_click_rate": { + "type": "number" }, - "instance": { - "type": "string", - "description": "Request path the failure occurred on." + "average_open_rate": { + "description": "Percentage, one decimal place.", + "type": "number" }, - "code": { - "type": "string", - "description": "Machine-readable lowercase error code, e.g. `scope_missing`." + "completed": { + "type": "integer" }, - "request_id": { - "type": "string", - "description": "Correlation id — quote it in support requests." + "total": { + "type": "integer" }, - "errors": { - "type": "array", + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" + } + }, + "required": [ + "total", + "active", + "completed", + "average_open_rate", + "average_click_rate", + "window" + ], + "type": "object" + }, + "AnalyticsTimeseriesV1": { + "description": "Daily email counters across the window. Every day in range is present, zero-filled.", + "properties": { + "data": { "items": { - "type": "object", "properties": { - "pointer": { - "type": "string", - "description": "RFC 6901 JSON Pointer to the offending field." + "bounces": { + "type": "integer" }, - "code": { - "type": "string" + "clicks": { + "type": "integer" }, - "message": { + "date": { + "format": "date-time", "type": "string" + }, + "delivered": { + "type": "integer" + }, + "emails": { + "type": "integer" + }, + "opens": { + "type": "integer" } }, "required": [ - "pointer", - "code", - "message" - ] + "date", + "emails", + "delivered", + "opens", + "clicks", + "bounces" + ], + "type": "object" }, - "description": "Field-level failures. Present on 422 `validation_error` responses." + "type": "array" + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" } }, "required": [ - "type", - "title", - "status", - "code" + "data", + "window" ], - "description": "RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface." + "type": "object" }, - "SuccessEmpty": { - "type": "object", + "AnalyticsTopCampaignsV1": { + "description": "Sent campaigns ranked by open rate.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] + "data": { + "items": { + "properties": { + "click_rate": { + "type": "number" + }, + "clicked": { + "type": "integer" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "open_rate": { + "type": "number" + }, + "opened": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "subject": { + "type": "string" + } + }, + "required": [ + "id", + "subject", + "sent", + "opened", + "clicked", + "open_rate", + "click_rate" + ], + "type": "object" + }, + "type": "array" + }, + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" } }, "required": [ - "success" + "data", + "window" ], - "description": "Bare success envelope with no payload." + "type": "object" }, - "IdResponse": { - "type": "object", + "AnalyticsWindowV1": { + "description": "The time range this response was computed over, after the 90-day clamp.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] + "from": { + "format": "date-time", + "type": "string" }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - } - }, - "required": [ - "id" - ] + "to": { + "format": "date-time", + "type": "string" } }, "required": [ - "success", - "data" + "from", + "to" ], - "description": "Success envelope carrying the affected resource's id, e.g. after a delete." + "type": "object" }, - "Contact": { - "type": "object", + "ApiKey": { + "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.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" }, - "projectId": { - "type": "string", - "format": "uuid" + "domainId": { + "format": "uuid", + "type": [ + "string", + "null" + ] }, - "email": { - "type": "string", - "format": "email" + "id": { + "format": "uuid", + "type": "string" }, - "subscribed": { - "type": "boolean" + "lastFour": { + "description": "Last 4 characters of the token — the only fragment of the secret that survives creation.", + "type": "string" }, - "customFields": { + "lastUsedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": [ - "object", + "string", "null" + ] + }, + "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" ], - "additionalProperties": {} + "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime 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" }, - "updatedAt": { - "type": "string", + "name": { + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "revokedAt": { + "description": "Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses.", "format": "date-time", - "description": "ISO 8601 datetime string" + "type": [ + "string", + "null" + ] + }, + "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 `legacyGrantPreset` instead at request time.", + "items": { + "enum": [ + "emails:send", + "emails:read", + "contacts:read", + "contacts:write", + "campaigns:read", + "campaigns:write", + "segments:read", + "segments:write", + "workflows:read", + "workflows:write", + "templates:read", + "templates:write", + "domains:read", + "domains:write", + "webhooks:read", + "webhooks:write", + "suppression:read", + "suppression:write", + "analytics:read", + "usage:read", + "events:read", + "events:write", + "projects:read", + "projects:write", + "api-keys:read", + "api-keys:write", + "campaigns:send", + "mailboxes:read", + "mailboxes:write", + "emails:test", + "deliverability:read", + "mailboxes:send", + "validation:read", + "validation:write", + "topics:read", + "topics:write", + "lists:read", + "lists:write" + ], + "type": "string" + }, + "type": "array" } }, "required": [ "id", "projectId", - "email", - "subscribed", + "name", + "lastFour", + "legacyGrantPreset", + "mode", + "scopes", + "domainId", + "lastUsedAt", "createdAt", - "updatedAt" + "revokedAt" ], - "description": "A subscriber/contact within a project." + "type": "object" }, - "ContactListResponse": { - "type": "object", + "ApiKeyListResponse": { + "description": "Every API key on the project, including revoked ones — filter on `revokedAt` for live keys.", "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ApiKey" + }, + "type": "array" + }, "success": { - "type": "boolean", "enum": [ true - ] - }, - "data": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Contact" - } - }, - "total": { - "type": "integer" - }, - "nextCursor": { - "type": [ - "string", - "null" - ], - "description": "Cursor for the next page, or null on the last page." - }, - "hasMore": { - "type": "boolean" - } - }, - "required": [ - "data", - "total", - "nextCursor", - "hasMore" - ] + ], + "type": "boolean" } }, "required": [ "success", "data" ], - "description": "Cursor-paginated list of contacts." + "type": "object" }, - "Template": { - "type": "object", + "AppPassword": { + "description": "An IMAP/SMTP credential for one mailbox, described but never reproduced.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "projectId": { - "type": "string", - "format": "uuid" - }, - "name": { + "createdAt": { + "format": "date-time", "type": "string" }, - "description": { - "type": [ - "string", - "null" - ] - }, - "subject": { + "id": { + "format": "uuid", "type": "string" }, - "body": { + "lastFour": { + "description": "The last four characters of the secret — enough to tell two credentials apart, and nothing more.", "type": "string" }, - "from": { - "type": "string", - "format": "email" - }, - "fromName": { + "lastUsedAt": { + "description": "Null until a mail client has authenticated with it at least once.", + "format": "date-time", "type": [ "string", "null" ] }, - "replyTo": { - "type": [ - "string", - "null" - ], - "format": "email" + "name": { + "description": "What the credential is for, e.g. `Thunderbird on my laptop`.", + "type": "string" }, - "emailCategory": { - "type": "string", - "enum": [ - "MARKETING", - "TRANSACTIONAL", - "SELF_MANAGED_UNSUBSCRIBE" - ] + "scopes": { + "description": "Which protocols this password may authenticate. `imap` reads, `smtp` sends.", + "items": { + "enum": [ + "imap", + "smtp" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "scopes", + "lastFour", + "lastUsedAt", + "createdAt" + ], + "type": "object" + }, + "AppPasswordReveal": { + "description": "A newly created app password, handed over as a one-time link rather than as a secret.", + "properties": { + "id": { + "format": "uuid", + "type": "string" }, - "createdAt": { - "type": "string", + "revealExpiresAt": { + "description": "When the link stops working. Five minutes after creation; the password itself does not expire.", "format": "date-time", - "description": "ISO 8601 datetime string" + "type": "string" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + "revealUrl": { + "description": "A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it.", + "format": "uri", + "type": "string" } }, "required": [ "id", - "projectId", - "name", - "subject", - "body", - "from", - "emailCategory", - "createdAt", - "updatedAt" + "revealUrl", + "revealExpiresAt" ], - "description": "A reusable email template." + "type": "object" }, - "TemplateListResponse": { - "type": "object", + "AssignDomainStream": { + "description": "Body for PATCH /api/domains/{id}.", "properties": { - "success": { - "type": "boolean", + "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": [ - true + "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": { "data": { - "type": "object", + "$ref": "#/components/schemas/SendEmailData" + }, + "error": { "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Template" - } - }, - "total": { - "type": "integer" - }, - "cursor": { - "type": "string", - "description": "Cursor for the next page; omitted on the last page." + "code": { + "type": "string" }, - "hasMore": { - "type": "boolean" + "message": { + "type": "string" } }, "required": [ - "data", - "total", - "hasMore" - ] + "message", + "code" + ], + "type": "object" + }, + "index": { + "type": "integer" + }, + "status": { + "enum": [ + "ok", + "error" + ], + "type": "string" } }, "required": [ - "success", - "data" + "index", + "status" ], - "description": "Cursor-paginated list of templates." + "type": "object" }, - "Snippet": { - "type": "object", + "BatchSendBody": { + "description": "Batch send wrapper. Up to 100 entries.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "projectId": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "description": "The literal identifier a template includes with `{{> name}}`." - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "body": { - "type": "string", - "description": "Template markup. Values it interpolates are escaped like any other." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + "emails": { + "items": { + "$ref": "#/components/schemas/SendEmail" + }, + "maxItems": 100, + "minItems": 1, + "type": "array" } }, "required": [ - "id", - "projectId", - "name", - "body", - "createdAt", - "updatedAt" + "emails" ], - "description": "A reusable fragment of template markup." + "type": "object" }, - "SnippetListResponse": { - "type": "object", + "BatchSendResponse": { + "description": "Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, "data": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Snippet" - } - }, - "total": { - "type": "integer" - }, - "cursor": { - "type": "string", - "description": "Cursor for the next page; omitted on the last page." - }, - "hasMore": { - "type": "boolean" - } + "items": { + "$ref": "#/components/schemas/BatchEntryResult" }, - "required": [ - "data", - "total", - "hasMore" - ] + "type": "array" + }, + "success": { + "type": "boolean" } }, "required": [ "success", "data" ], - "description": "Cursor-paginated list of snippets." - }, - "EmailDeliveryStatus": { - "type": "string", - "enum": [ - "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "BOUNCED", - "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" - ], - "description": "Delivery lifecycle of the message. Engagement is reported separately." + "type": "object" }, - "Domain": { - "type": "object", + "CampaignV1": { + "description": "A campaign as exposed on the v1 API.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "projectId": { - "type": "string", - "format": "uuid" + "audience_type": { + "enum": [ + "ALL", + "FILTERED", + "SEGMENT", + "LIST" + ], + "type": "string" }, - "name": { + "created_at": { + "format": "date-time", "type": "string" }, - "verified": { - "type": "boolean" + "id": { + "format": "uuid", + "type": "string" }, - "region": { + "list_id": { + "format": "uuid", "type": [ "string", "null" ] }, - "dkim": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "value" - ] - } + "name": { + "type": "string" }, - "mailFromDomain": { + "scheduled_at": { + "format": "date-time", "type": [ "string", "null" - ], - "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`)." + ] }, - "mailFromDomainStatus": { + "sent_at": { + "format": "date-time", "type": [ "string", "null" - ], - "enum": [ - "Pending", - "Success", - "Failed", - "TemporaryFailure", - "NotConfigured", - null - ], - "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it." + ] }, - "stream": { - "type": [ - "string", - "null" - ], + "stats": { + "properties": { + "bounced": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "total_recipients": { + "type": "integer" + } + }, + "required": [ + "total_recipients", + "sent", + "delivered", + "opened", + "clicked", + "bounced" + ], + "type": "object" + }, + "status": { "enum": [ - "TRANSACTIONAL", - "MARKETING", - null + "DRAFT", + "SCHEDULED", + "SENDING", + "PAUSED", + "SENT", + "CANCELLED" ], - "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." + "type": "string" }, - "streamDefault": { - "type": "boolean", - "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)." + "subject": { + "type": "string" }, - "defaultFromAddress": { + "topic_id": { + "format": "uuid", "type": [ "string", "null" - ], - "description": "The address a send on this stream uses when it names none. Always on this identity's own host." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + ] } }, "required": [ "id", - "projectId", "name", - "verified", - "createdAt", - "updatedAt" - ], - "description": "A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation." - }, - "DomainListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Domain" - } - } - }, - "required": [ - "success", - "data" + "status", + "subject", + "audience_type", + "list_id", + "topic_id", + "scheduled_at", + "sent_at", + "created_at", + "stats" ], - "description": "List of all domains for the auth'd project." + "type": "object" }, - "DomainVerificationStatus": { - "type": "object", + "CampaignV1Create": { + "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": { - "verified": { - "type": "boolean" - }, - "mxRecords": { - "type": "array", - "items": { - "type": "string" - } - }, - "dkim": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "value" - ] - } - }, - "mailFromDomain": { - "type": [ - "string", - "null" - ] + "audience_condition": { + "$ref": "#/components/schemas/FilterConditionV1" }, - "mailFromDomainStatus": { - "type": [ - "string", - "null" - ], + "audience_type": { + "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": [ - "Pending", - "Success", - "Failed", - "TemporaryFailure", - "NotConfigured", - null + "ALL", + "FILTERED", + "SEGMENT", + "LIST" ], - "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it." - } - }, - "required": [ - "verified" - ], - "description": "Outcome of a verification check against SES." - }, - "Email": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "projectId": { - "type": "string", - "format": "uuid" + "type": "string" }, - "from": { + "body": { + "minLength": 1, "type": "string" }, - "to": { + "description": { + "maxLength": 500, "type": "string" }, - "subject": { + "email_category": { + "default": "MARKETING", + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ], "type": "string" }, - "status": { - "$ref": "#/components/schemas/EmailDeliveryStatus" + "from": { + "description": "Sender address. Its domain must be verified for this project.", + "format": "email", + "type": "string" }, - "openedAt": { + "from_name": { + "maxLength": 100, "type": [ "string", "null" - ], - "format": "date-time", - "description": "First open, or null." + ] }, - "clickedAt": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "First click, or null." + "list_id": { + "description": "Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign.", + "format": "uuid", + "type": "string" }, - "complainedAt": { + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "reply_to": { + "format": "email", "type": [ "string", "null" - ], - "format": "date-time", - "description": "Spam complaint, or null." - }, - "opens": { - "type": "integer", - "description": "Total opens recorded." + ] }, - "clicks": { - "type": "integer", - "description": "Total clicks recorded." + "segment_id": { + "format": "uuid", + "type": "string" }, - "tags": { - "type": "array", - "items": { - "type": "string" - } + "subject": { + "minLength": 1, + "type": "string" }, - "error": { + "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" ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" } }, "required": [ - "id", - "projectId", - "from", - "to", + "name", "subject", - "status", - "openedAt", - "clickedAt", - "complainedAt", - "opens", - "clicks", - "tags", - "createdAt", - "updatedAt" + "body", + "from", + "audience_type" ], - "description": "A sent (or queued) transactional email." + "type": "object" }, - "EmailEvent": { - "type": "object", + "CampaignV1Deleted": { + "description": "Acknowledgement that a campaign was deleted.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "status": { - "$ref": "#/components/schemas/EmailDeliveryStatus" + "deleted": { + "enum": [ + true + ], + "type": "boolean" }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + "id": { + "format": "uuid", + "type": "string" } }, "required": [ "id", - "status", - "timestamp" + "deleted" ], - "description": "One transition in a message's delivery history." + "type": "object" }, - "EmailWithEvents": { - "allOf": [ - { - "$ref": "#/components/schemas/Email" - }, - { - "type": "object", - "properties": { - "events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EmailEvent" - }, - "description": "Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource." - } - }, - "required": [ - "events" - ] - } - ], - "description": "A transactional email together with its delivery history." - }, - "SendEmailRecipientResult": { - "type": "object", + "CampaignV1Failure": { + "description": "A campaign recipient whose send did not complete.", "properties": { - "contact": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "id", - "email" - ] + "contact_id": { + "format": "uuid", + "type": "string" }, "email": { - "type": "string", - "format": "uuid" - } - }, - "required": [ - "contact", - "email" - ], - "description": "Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient." - }, - "SendEmailData": { - "type": "object", - "properties": { - "emails": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SendEmailRecipientResult" - } + "description": "The recipient the send was for.", + "type": "string" }, - "timestamp": { - "type": "string", + "failed_at": { "format": "date-time", - "description": "ISO 8601 datetime string" + "type": "string" + }, + "id": { + "description": "Ledger row id. Pass the last one as `after` to page.", + "format": "uuid", + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] } }, "required": [ - "emails", - "timestamp" + "id", + "contact_id", + "email", + "reason", + "failed_at" ], - "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`." + "type": "object" }, - "SendEmailResponse": { - "type": "object", + "CampaignV1FailureList": { + "description": "Cursor-paginated list of a campaign's failed sends.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true + "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" ] }, - "data": { - "$ref": "#/components/schemas/SendEmailData" + "total": { + "description": "Every FAILED row on this campaign, not just this page.", + "type": "integer" } }, "required": [ - "success", - "data" + "data", + "has_more", + "next_cursor", + "total" ], - "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." + "type": "object" }, - "BatchEntryResult": { - "type": "object", + "CampaignV1List": { + "description": "Cursor-paginated list of campaigns.", "properties": { - "index": { - "type": "integer" - }, - "status": { - "type": "string", - "enum": [ - "ok", - "error" - ] - }, "data": { - "$ref": "#/components/schemas/SendEmailData" - }, - "error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "code": { - "type": "string" - } + "items": { + "$ref": "#/components/schemas/CampaignV1" }, - "required": [ - "message", - "code" + "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": [ - "index", - "status" + "data", + "has_more", + "next_cursor" ], - "description": "Per-row result in a batch send response." + "type": "object" }, - "BatchSendResponse": { - "type": "object", + "CampaignV1RetryFailed": { + "description": "Acknowledgement that a retry of a campaign's failed sends began.", "properties": { - "success": { - "type": "boolean" + "id": { + "format": "uuid", + "type": "string" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BatchEntryResult" - } + "queued": { + "description": "How many FAILED rows the retry walk was started for, counted when it was queued.", + "type": "integer" } }, "required": [ - "success", - "data" + "id", + "queued" ], - "description": "Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200." + "type": "object" }, - "BatchSendBody": { - "type": "object", + "CampaignV1Send": { + "description": "Body for POST /api/v1/campaigns/{id}/send.", "properties": { - "emails": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SendEmail" - }, - "minItems": 1, - "maxItems": 100 + "scheduled_for": { + "description": "RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately.", + "format": "date-time", + "type": "string" } }, - "required": [ - "emails" - ], - "description": "Batch send wrapper. Up to 100 entries." + "type": "object" }, - "SendEmail": { - "type": "object", + "CampaignV1Stats": { + "description": "Materialized delivery and engagement counters for one campaign.", "properties": { - "to": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "email" - ] - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "email" - ] - } - ] - } - } - ] - }, - "subject": { - "type": "string", - "minLength": 1, - "maxLength": 998, - "pattern": "^[^\\r\\n]*$" + "bounce_rate": { + "type": "number" }, - "body": { - "type": "string", - "minLength": 1 + "bounced": { + "type": "integer" }, - "template": { - "type": "string", - "format": "uuid" + "click_rate": { + "type": "number" }, - "subscribed": { - "type": "boolean" + "clicked": { + "type": "integer" }, - "name": { - "type": "string" + "delivered": { + "type": "integer" }, - "from": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "object", - "properties": { - "name": { - "type": "string", - "pattern": "^[^\\r\\n]*$" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "email" - ] - } - ] + "delivery_rate": { + "type": "number" }, - "reply": { - "type": "string", - "format": "email" + "open_rate": { + "type": "number" }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 998, - "pattern": "^[^\\r\\n]*$" - } + "opened": { + "type": "integer" }, - "data": { - "type": "object", - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + "sent": { + "type": "integer" }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[^\\r\\n\"]+$" - }, - "content": { - "type": "string", - "minLength": 1 - }, - "contentType": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "contentId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[^<>\\r\\n]+$" - }, - "disposition": { - "type": "string", - "enum": [ - "attachment", - "inline" - ], - "default": "attachment" - } - }, - "required": [ - "filename", - "content", - "contentType" - ] - }, - "maxItems": 10 - }, - "tags": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "maxItems": 10 - }, - "cc": { - "type": "array", - "items": { - "type": "string", - "format": "email" - } - }, - "bcc": { - "type": "array", - "items": { - "type": "string", - "format": "email" - } - } - }, - "required": [ - "to" - ], - "description": "Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required." - }, - "ContactBulkCreateBody": { - "type": "object", - "properties": { - "contacts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateContact" - }, - "minItems": 1, - "maxItems": 1000 + "total_recipients": { + "type": "integer" } }, "required": [ - "contacts" + "total_recipients", + "sent", + "delivered", + "opened", + "clicked", + "bounced", + "open_rate", + "click_rate", + "bounce_rate", + "delivery_rate" ], - "description": "Bulk create up to 1000 contacts." + "type": "object" }, - "CreateContact": { - "type": "object", + "CampaignV1Update": { + "description": "Body for PATCH /api/v1/campaigns/{id}. All fields optional.", "properties": { - "email": { - "type": "string", - "format": "email" + "audience_condition": { + "$ref": "#/components/schemas/FilterConditionV1" }, - "subscribed": { - "type": "boolean", - "default": true + "audience_type": { + "enum": [ + "ALL", + "FILTERED", + "SEGMENT", + "LIST" + ], + "type": "string" }, - "customFields": { - "type": "object", - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + "body": { + "minLength": 1, + "type": "string" + }, + "description": { + "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", + "type": "string" + }, + "from_name": { + "maxLength": 100, + "type": [ + "string", + "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, + "type": "string" + }, + "reply_to": { + "format": "email", + "type": [ + "string", + "null" + ] + }, + "segment_id": { + "format": "uuid", + "type": "string" + }, + "subject": { + "minLength": 1, + "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": [ - "email" - ], - "description": "Body for POST /api/contacts and /api/contacts/upsert." + "type": "object" }, - "ContactBulkDeleteBody": { - "type": "object", + "ComposeMailboxMessage": { + "description": "Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox.", "properties": { - "ids": { - "type": "array", + "bcc": { "items": { - "type": "string", - "format": "uuid" + "format": "email", + "type": "string" }, - "maxItems": 1000 + "maxItems": 20, + "type": "array" }, - "emails": { - "type": "array", + "body": { + "maxLength": 50000, + "minLength": 1, + "type": "string" + }, + "cc": { "items": { - "type": "string", - "format": "email" + "format": "email", + "type": "string" }, - "maxItems": 1000 - } - }, - "description": "Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each)." - }, - "UpdateContactBody": { - "type": "object", - "properties": { - "subscribed": { - "type": "boolean" + "maxItems": 20, + "type": "array" }, - "customFields": { - "type": "object", - "additionalProperties": {} + "subject": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "to": { + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 20, + "minItems": 1, + "type": "array" } }, - "description": "Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses." + "required": [ + "to", + "subject", + "body" + ], + "type": "object" }, - "Suppression": { - "type": "object", + "Contact": { + "description": "A subscriber/contact within a project.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" }, - "projectId": { - "type": "string", - "format": "uuid" + "customFields": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] }, "email": { - "type": "string", - "format": "email" + "format": "email", + "type": "string" }, - "reason": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ] + "id": { + "format": "uuid", + "type": "string" }, - "source": { - "type": "string", - "enum": [ - "SES_WEBHOOK", - "API", - "DASHBOARD" - ] + "projectId": { + "format": "uuid", + "type": "string" }, - "createdAt": { - "type": "string", + "subscribed": { + "type": "boolean" + }, + "updatedAt": { + "description": "ISO 8601 datetime string", "format": "date-time", - "description": "ISO 8601 datetime string" + "type": "string" } }, "required": [ "id", "projectId", "email", - "reason", - "source", - "createdAt" + "subscribed", + "createdAt", + "updatedAt" ], - "description": "A single suppressed-email record." + "type": "object" }, - "SuppressionListResponse": { - "type": "object", + "ContactBulkCreateBody": { + "description": "Bulk create up to 1000 contacts.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "array", + "contacts": { "items": { - "$ref": "#/components/schemas/Suppression" - } - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "cursor": { - "type": [ - "string", - "null" - ] - }, - "hasMore": { - "type": "boolean" + "$ref": "#/components/schemas/CreateContact" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" } }, "required": [ - "success", - "data" + "contacts" ], - "description": "Cursor-paginated list of suppressions." + "type": "object" }, - "SuppressionCheckResponse": { - "type": "object", + "ContactBulkDeleteBody": { + "description": "Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each).", "properties": { - "suppressed": { - "type": "boolean" + "emails": { + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 1000, + "type": "array" }, - "reason": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ] + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "maxItems": 1000, + "type": "array" + } + }, + "type": "object" + }, + "ContactListResponse": { + "description": "Cursor-paginated list of contacts.", + "properties": { + "data": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Contact" + }, + "type": "array" + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "description": "Cursor for the next page, or null on the last page.", + "type": [ + "string", + "null" + ] + }, + "total": { + "type": "integer" + } + }, + "required": [ + "data", + "total", + "nextCursor", + "hasMore" + ], + "type": "object" }, - "source": { - "type": "string", + "success": { "enum": [ - "SES_WEBHOOK", - "API", - "DASHBOARD" - ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + true + ], + "type": "boolean" } }, "required": [ - "suppressed" + "success", + "data" ], - "description": "Result of GET /api/suppression/{email} — whether the address is suppressed." + "type": "object" }, - "Webhook": { - "type": "object", + "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": { - "id": { - "type": "string", - "format": "uuid" - }, - "projectId": { - "type": "string", - "format": "uuid" + "contact_id": { + "type": "string" }, - "url": { - "type": "string", - "format": "uri" + "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" }, - "eventTypes": { - "type": "array", + "topics": { "items": { - "type": "string", - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" - ] - } - }, - "status": { - "type": "string", - "enum": [ - "ACTIVE", - "PAUSED", - "DISABLED" - ] - }, - "consecutiveFailures": { - "type": "integer" + "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" }, - "disabledAt": { + "custom_fields": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", "type": [ "string", + "number", + "boolean", + "object", + "array", "null" - ], - "format": "date-time", - "description": "ISO 8601 datetime string" + ] }, - "lastFour": { + "email": { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + "id": { + "format": "uuid", + "type": "string" }, - "updatedAt": { - "type": "string", + "subscribed": { + "type": "boolean" + }, + "updated_at": { "format": "date-time", - "description": "ISO 8601 datetime string" + "type": "string" } }, "required": [ "id", - "projectId", - "url", - "eventTypes", - "status", - "consecutiveFailures", - "createdAt", - "updatedAt" + "email", + "subscribed", + "custom_fields", + "created_at", + "updated_at" ], - "description": "A user-managed outbound webhook." + "type": "object" }, - "WebhookCreateResponse": { - "type": "object", + "ContactV1Create": { + "description": "Body for POST /api/v1/contacts.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true + "custom_fields": { + "additionalProperties": {}, + "description": "Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`.", + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" ] }, - "data": { - "allOf": [ - { - "$ref": "#/components/schemas/Webhook" - }, - { - "type": "object", - "properties": { - "secret": { - "type": "string", - "description": "Plaintext shared secret. Returned ONCE on create." - } - }, - "required": [ - "secret" - ] - } - ], - "description": "A user-managed outbound webhook." + "email": { + "format": "email", + "type": "string" + }, + "subscribed": { + "default": true, + "type": "boolean" } }, "required": [ - "success", - "data" + "email" ], - "description": "Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely." + "type": "object" }, - "WebhookListResponse": { - "type": "object", + "ContactV1Deleted": { + "description": "Acknowledgement that a contact was deleted.", "properties": { - "success": { - "type": "boolean", + "deleted": { "enum": [ true - ] + ], + "type": "boolean" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Webhook" - } + "id": { + "format": "uuid", + "type": "string" } }, "required": [ - "success", - "data" + "id", + "deleted" ], - "description": "List of webhooks for the auth'd project." + "type": "object" }, - "WebhookGetResponse": { - "type": "object", + "ContactV1List": { + "description": "Cursor-paginated list of contacts.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, "data": { - "$ref": "#/components/schemas/Webhook" + "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": [ - "success", - "data" + "data", + "has_more", + "next_cursor" ], - "description": "Single webhook (no secret)." + "type": "object" }, - "WebhookRotateSecretResponse": { - "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": { - "success": { - "type": "boolean", - "enum": [ - true + "custom_fields": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" ] }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "secret": { - "type": "string", - "description": "New plaintext shared secret." - } - }, - "required": [ - "id", - "secret" + "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", + "campaigns:read", + "campaigns:write", + "segments:read", + "segments:write", + "workflows:read", + "workflows:write", + "templates:read", + "templates:write", + "domains:read", + "domains:write", + "webhooks:read", + "webhooks:write", + "suppression:read", + "suppression:write", + "analytics:read", + "usage:read", + "events:read", + "events:write", + "projects:read", + "projects:write", + "api-keys:read", + "api-keys:write", + "campaigns:send", + "mailboxes:read", + "mailboxes:write", + "emails:test", + "deliverability:read", + "mailboxes:send", + "validation:read", + "validation:write", + "topics:read", + "topics:write", + "lists:read", + "lists:write" + ], + "type": "string" + }, + "type": "array" } }, "required": [ - "success", - "data" + "name" ], - "description": "Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once." + "type": "object" }, - "WebhookCall": { - "type": "object", + "CreateAppPassword": { + "description": "Body for POST /api/mailboxes/:id/app-passwords.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "name": { + "maxLength": 60, + "minLength": 1, + "type": "string" }, - "webhookId": { - "type": "string", - "format": "uuid" + "scopes": { + "default": [ + "imap", + "smtp" + ], + "items": { + "enum": [ + "imap", + "smtp" + ], + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CreateContact": { + "description": "Body for POST /api/contacts and /api/contacts/upsert.", + "properties": { + "customFields": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", + "type": "object" }, - "eventType": { + "email": { + "format": "email", "type": "string" }, - "payload": { - "type": "object", - "additionalProperties": {} + "subscribed": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "CreateMailboxBody": { + "properties": { + "displayName": { + "maxLength": 120, + "type": "string" }, - "status": { - "type": "string", - "enum": [ - "PENDING", - "SUCCESS", - "FAILED" - ] + "domainId": { + "description": "A VERIFIED domain belonging to this project.", + "format": "uuid", + "type": "string" }, - "attempt": { - "type": "integer" + "localPart": { + "description": "The part before the `@`, e.g. `support`. Lowercased server-side.", + "maxLength": 64, + "minLength": 1, + "type": "string" }, - "responseStatus": { - "type": [ - "integer", - "null" - ] + "projectId": { + "description": "Defaults to the project the credential resolves to. Naming a different one is refused.", + "format": "uuid", + "type": "string" }, - "responseBody": { + "quotaBytes": { + "description": "NOT IMPLEMENTED — sending any value answers 400.", + "exclusiveMinimum": 0, + "type": "integer" + } + }, + "required": [ + "domainId", + "localPart" + ], + "type": "object" + }, + "CreateSnippet": { + "description": "Body for POST /api/snippets.", + "properties": { + "body": { + "maxLength": 20000, + "minLength": 1, + "type": "string" + }, + "description": { + "maxLength": 500, "type": [ "string", "null" ] }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + "name": { + "pattern": "^[a-z][\\da-z_-]{0,63}$/i", + "type": "string" } }, "required": [ - "id", - "webhookId", - "eventType", - "payload", - "status", - "attempt", - "createdAt" + "name", + "body" ], - "description": "An attempted webhook delivery." + "type": "object" }, - "WebhookCallsListResponse": { - "type": "object", + "CreateTemplate": { + "description": "Body for POST /api/templates.", "properties": { - "success": { - "type": "boolean", + "body": { + "minLength": 1, + "type": "string" + }, + "description": { + "maxLength": 500, + "type": "string" + }, + "emailCategory": { + "default": "MARKETING", "enum": [ - true - ] + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ], + "type": "string" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebhookCall" - } + "from": { + "format": "email", + "type": "string" }, - "nextCursor": { + "fromName": { + "maxLength": 100, "type": [ "string", "null" ] }, - "cursor": { + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "replyTo": { + "format": "email", "type": [ "string", "null" ] }, - "hasMore": { - "type": "boolean" + "subject": { + "minLength": 1, + "type": "string" } }, "required": [ - "success", - "data" + "name", + "subject", + "body", + "from" ], - "description": "Cursor-paginated list of recent calls for a single webhook." + "type": "object" }, - "TrackEventResponse": { - "type": "object", + "CreateWebhook": { + "description": "Body for POST /api/webhooks — register a user webhook for one or more events.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "contact": { - "type": "string", - "format": "uuid" - }, - "event": { - "type": "string", - "format": "uuid" - }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" - } + "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" }, - "required": [ - "contact", - "event", - "timestamp" - ] + "minItems": 1, + "type": "array" + }, + "url": { + "format": "uri", + "type": "string" } }, "required": [ - "success", - "data" + "url", + "eventTypes" ], - "description": "Response from POST /api/track." + "type": "object" }, - "VerifyEmailResponse": { - "type": "object", + "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": { - "success": { - "type": "boolean", - "enum": [ - true + "address": { + "type": [ + "string", + "null" ] }, - "data": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "valid": { - "type": "boolean" - }, - "reason": { - "type": "string" - } + "checked_at": { + "format": "date-time", + "type": "string" + }, + "domain": { + "type": "string" + }, + "findings": { + "description": "What is wrong, worst first. An empty array means nothing here explains a delivery problem.", + "items": { + "$ref": "#/components/schemas/DeliverabilityFindingV1" }, - "required": [ - "email", - "valid" - ], - "additionalProperties": {} + "type": "array" + }, + "identity": { + "$ref": "#/components/schemas/DeliverabilityIdentityV1" + }, + "recent_delivery": { + "$ref": "#/components/schemas/DeliverabilityRecentDeliveryV1" + }, + "suppression": { + "$ref": "#/components/schemas/DeliverabilitySuppressionV1" } }, "required": [ - "success", - "data" + "domain", + "address", + "checked_at", + "identity", + "suppression", + "recent_delivery", + "findings" ], - "description": "Response from POST /api/verify — outcome of the syntax/MX/disposable check." + "type": "object" }, - "EmailListResponse": { - "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": { - "success": { - "type": "boolean", - "enum": [ - true - ] + "code": { + "description": "Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`.", + "type": "string" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Email" - } + "remedy": { + "description": "What to do about it.", + "type": "string" }, - "nextCursor": { - "type": [ - "string", - "null" - ] + "severity": { + "$ref": "#/components/schemas/DeliverabilityFindingSeverityV1" + }, + "summary": { + "description": "What is wrong, in one sentence.", + "type": "string" } }, "required": [ - "success", - "data" + "code", + "severity", + "summary", + "remedy" ], - "description": "Cursor-paginated list of emails." + "type": "object" }, - "EmailResponse": { - "type": "object", + "DeliverabilityIdentityV1": { + "description": "The sending identity's DNS health, as last refreshed.", "properties": { - "success": { - "type": "boolean", + "dkim_status": { + "description": "DKIM signing. This is the one that decides whether Sendly will send from the domain at all.", "enum": [ - true + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "type": [ + "string", + "null" ] }, - "data": { - "$ref": "#/components/schemas/Email" - } - }, - "required": [ - "success", - "data" - ], - "description": "A single email." - }, - "EmailDetailResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", + "dmarc_status": { + "description": "The DMARC policy published at `_dmarc.`.", "enum": [ - true + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "type": [ + "string", + "null" ] }, - "data": { - "$ref": "#/components/schemas/EmailWithEvents" + "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" + ] + }, + "mail_from_domain": { + "type": [ + "string", + "null" + ] + }, + "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" + ] + }, + "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": [ - "success", - "data" + "registered", + "verified", + "dkim_status", + "spf_status", + "dmarc_status", + "mx_status", + "mail_from_domain", + "mail_from_domain_status", + "last_checked_at" ], - "description": "One email and its delivery history." + "type": "object" }, - "ListSubscribeResponse": { - "type": "object", + "DeliverabilityRecentDeliveryV1": { + "description": "Delivery outcomes over the requested window.", "properties": { - "success": { - "type": "boolean", - "enum": [ - true + "bounce_rate": { + "description": "Bounced ÷ sent (0–1), or null when nothing was sent in the window.", + "type": [ + "number", + "null" ] }, - "data": { - "type": "object", - "properties": { - "membershipId": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string", - "enum": [ - "PENDING", - "CONFIRMED", - "UNSUBSCRIBED" - ] - }, - "created": { - "type": "boolean", - "description": "True when the membership row did not exist before this call." - }, - "previousStatus": { - "type": [ - "string", - "null" - ], - "enum": [ - "PENDING", - "CONFIRMED", - "UNSUBSCRIBED", - null - ], - "description": "Status the membership held before this call; null when it did not exist. Use this rather than `created` to describe the transition to the user." - }, - "confirmToken": { - "type": "string", - "description": "Present only when the list has doubleOptIn enabled. Sendly does not send the confirmation email — deliver /api/lists/confirm-subscription?token= to the contact. Valid for 24 hours." - } - }, - "required": [ - "membershipId", - "status", - "created", - "previousStatus" + "bounced": { + "type": "integer" + }, + "complained": { + "type": "integer" + }, + "complaint_rate": { + "type": [ + "number", + "null" ] - } - }, - "required": [ - "success", - "data" - ], - "description": "Result of a list-subscribe call." - }, - "ListUnsubscribeResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", + }, + "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": "string" }, - "data": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "email" - ] + "sent": { + "type": "integer" + }, + "window_days": { + "type": "integer" } }, "required": [ - "success", - "data" + "window_days", + "scope", + "sent", + "delivered", + "bounced", + "complained", + "failed", + "bounce_rate", + "complaint_rate" ], - "description": "Echoes the address that was unsubscribed." + "type": "object" }, - "ApiKey": { - "type": "object", + "DeliverabilitySuppressionV1": { + "description": "Null unless the request named an `address`.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "projectId": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "lastFour": { - "type": "string", - "description": "Last 4 characters of the token — the only fragment of the secret that survives creation." - }, - "legacyGrantPreset": { - "type": "string", - "enum": [ - "FULL", - "SENDING_ONLY" - ], - "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." - }, - "mode": { - "type": "string", + "reason": { "enum": [ - "LIVE", - "TEST" + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE", + null ], - "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." - }, - "scopes": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "emails:send", - "emails:read", - "contacts:read", - "contacts:write", - "campaigns:read", - "campaigns:write", - "segments:read", - "segments:write", - "workflows:read", - "workflows:write", - "templates:read", - "templates:write", - "domains:read", - "domains:write", - "webhooks:read", - "webhooks:write", - "suppression:read", - "suppression:write", - "analytics:read", - "usage:read", - "events:read", - "events:write", - "projects:read", - "projects:write", - "api-keys:read", - "api-keys:write", - "campaigns:send", - "mailboxes:read", - "mailboxes:write", - "emails:test", - "deliverability:read", - "mailboxes:send", - "validation:read", - "validation:write", - "topics:read", - "topics:write", - "lists:read", - "lists:write" - ] - }, - "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." - }, - "domainId": { "type": [ "string", "null" - ], - "format": "uuid" + ] }, - "lastUsedAt": { + "source": { + "enum": [ + "SES_WEBHOOK", + "API", + "DASHBOARD", + null + ], "type": [ "string", "null" - ], - "format": "date-time", - "description": "ISO 8601 datetime string" + ] }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + "suppressed": { + "type": "boolean" }, - "revokedAt": { + "suppressed_at": { + "format": "date-time", "type": [ "string", "null" - ], - "format": "date-time", - "description": "Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses." - } - }, - "required": [ - "id", - "projectId", - "name", - "lastFour", - "legacyGrantPreset", - "mode", - "scopes", - "domainId", - "lastUsedAt", - "createdAt", - "revokedAt" - ], - "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." - }, - "ApiKeyListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true ] - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ApiKey" - } } }, "required": [ - "success", - "data" + "suppressed", + "reason", + "source", + "suppressed_at" ], - "description": "Every API key on the project, including revoked ones — filter on `revokedAt` for live keys." + "type": [ + "object", + "null" + ] }, - "ProjectRecord": { - "type": "object", + "DmarcReportV1": { + "description": "One DMARC aggregate (RUA) report.", "properties": { + "fail_count": { + "type": "integer" + }, "id": { - "type": "string", - "format": "uuid" + "type": "string" }, - "name": { + "org_name": { + "description": "The reporting receiver, e.g. `google.com`.", "type": "string" }, - "disabled": { - "type": "boolean" + "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" }, - "disabledReason": { - "type": [ - "string", - "null" - ] + "policy_domain": { + "description": "The domain of yours the report is about.", + "type": "string" }, - "sandboxHandle": { - "type": [ - "string", - "null" - ], - "description": "Local-part of the sandbox quick-start sender; null until first derived." + "range_begin": { + "format": "date-time", + "type": "string" }, - "stripeCustomerId": { - "type": [ - "string", - "null" - ] + "range_end": { + "format": "date-time", + "type": "string" }, - "stripeSubscriptionId": { - "type": [ - "string", - "null" - ] + "received_at": { + "format": "date-time", + "type": "string" }, - "billingLimitWorkflows": { - "type": [ - "integer", - "null" - ] + "report_id": { + "description": "The receiver's own id for this report.", + "type": "string" }, - "billingLimitCampaigns": { - "type": [ - "integer", + "sources": { + "description": "Per-sending-source rows, as the receiver reported them.", + "items": { + "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" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "id", + "report_id", + "org_name", + "policy_domain", + "range_begin", + "range_end", + "total_count", + "pass_count", + "fail_count", + "sources", + "received_at" + ], + "type": "object" + }, + "DmarcReportV1List": { + "description": "Cursor-paginated DMARC aggregate reports, newest window first.", + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DmarcReportV1" + }, + "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" + }, + "Domain": { + "description": "A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation.", + "properties": { + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" }, - "billingLimitTransactional": { + "defaultFromAddress": { + "description": "The address a send on this stream uses when it names none. Always on this identity's own host.", "type": [ - "integer", + "string", "null" ] }, - "billingLimitInbound": { + "dkim": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "mailFromDomain": { + "description": "Custom MAIL FROM subdomain SES has on record (normally `sendly.`).", "type": [ - "integer", + "string", "null" ] }, - "tracking": { - "type": "string", + "mailFromDomainStatus": { + "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.", "enum": [ - "ENABLED", - "DISABLED", - "MARKETING_ONLY" + "Pending", + "Success", + "Failed", + "TemporaryFailure", + "NotConfigured", + null + ], + "type": [ + "string", + "null" ] }, - "sesRegion": { + "name": { + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "region": { "type": [ "string", "null" ] }, - "language": { - "type": "string", - "description": "ISO 639-1 code for customer-facing content." - }, - "organizationId": { + "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" ] }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime string" + "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": { - "type": "string", + "description": "ISO 8601 datetime string", "format": "date-time", - "description": "ISO 8601 datetime string" + "type": "string" + }, + "verified": { + "type": "boolean" } }, "required": [ "id", + "projectId", "name", - "disabled", - "disabledReason", - "sandboxHandle", - "stripeCustomerId", - "stripeSubscriptionId", - "billingLimitWorkflows", - "billingLimitCampaigns", - "billingLimitTransactional", - "billingLimitInbound", - "tracking", - "sesRegion", - "language", - "organizationId", + "verified", "createdAt", "updatedAt" - ] + ], + "type": "object" }, - "CampaignV1List": { - "type": "object", + "DomainListResponse": { + "description": "List of all domains for the auth'd project.", "properties": { "data": { - "type": "array", "items": { - "$ref": "#/components/schemas/CampaignV1" - } - }, - "has_more": { - "type": "boolean" + "$ref": "#/components/schemas/Domain" + }, + "type": "array" }, - "next_cursor": { - "type": [ - "string", - "null" + "success": { + "enum": [ + true ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + "type": "boolean" } }, "required": [ - "data", - "has_more", - "next_cursor" + "success", + "data" ], - "description": "Cursor-paginated list of campaigns." + "type": "object" }, - "CampaignV1": { - "type": "object", + "DomainV1": { + "description": "A sending domain as exposed on the v1 API.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { + "created_at": { + "format": "date-time", "type": "string" }, - "status": { - "type": "string", - "enum": [ - "DRAFT", - "SCHEDULED", - "SENDING", - "PAUSED", - "SENT", - "CANCELLED" + "default_from_address": { + "type": [ + "string", + "null" ] }, - "subject": { + "dkim_verified": { + "type": "boolean" + }, + "domain": { "type": "string" }, - "audience_type": { - "type": "string", - "enum": [ - "ALL", - "FILTERED", - "SEGMENT", - "LIST" - ] + "id": { + "format": "uuid", + "type": "string" }, - "list_id": { + "mail_from_domain": { "type": [ "string", "null" - ], - "format": "uuid" + ] }, - "topic_id": { + "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" - ], - "format": "uuid" + ] }, - "scheduled_at": { + "region": { "type": [ "string", "null" - ], - "format": "date-time" + ] }, - "sent_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "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" + ] + } + ] }, - "created_at": { - "type": "string", - "format": "date-time" + "stream_default": { + "type": "boolean" }, - "stats": { - "type": "object", - "properties": { - "total_recipients": { - "type": "integer" - }, - "sent": { - "type": "integer" - }, - "delivered": { - "type": "integer" - }, - "opened": { - "type": "integer" - }, - "clicked": { - "type": "integer" - }, - "bounced": { - "type": "integer" - } - }, - "required": [ - "total_recipients", - "sent", - "delivered", - "opened", - "clicked", - "bounced" - ] + "updated_at": { + "format": "date-time", + "type": "string" + }, + "verified": { + "type": "boolean" } }, "required": [ "id", - "name", - "status", - "subject", - "audience_type", - "list_id", - "topic_id", - "scheduled_at", - "sent_at", + "domain", + "verified", + "region", + "stream", + "stream_default", + "default_from_address", + "mail_from_domain", + "mail_from_domain_status", + "dkim_verified", "created_at", - "stats" + "updated_at" ], - "description": "A campaign as exposed on the v1 API." + "type": "object" }, - "CampaignV1Create": { - "type": "object", + "DomainV1Create": { + "description": "Body for POST /api/v1/domains.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "maxLength": 500 - }, - "subject": { - "type": "string", - "minLength": 1 - }, - "body": { - "type": "string", - "minLength": 1 - }, - "from": { - "type": "string", - "format": "email", - "description": "Sender address. Its domain must be verified for this project." - }, - "from_name": { - "type": [ - "string", - "null" - ], - "maxLength": 100 - }, - "reply_to": { - "type": [ - "string", - "null" - ], - "format": "email" - }, - "email_category": { - "type": "string", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" - ], - "default": "MARKETING" + "domain": { + "maxLength": 253, + "minLength": 3, + "type": "string" }, - "audience_type": { - "type": "string", + "region": { + "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed.", "enum": [ - "ALL", - "FILTERED", - "SEGMENT", - "LIST" + "us-east-1", + "us-west-2", + "eu-west-1" ], - "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." - }, - "audience_condition": { - "$ref": "#/components/schemas/FilterConditionV1" - }, - "segment_id": { - "type": "string", - "format": "uuid" + "type": "string" }, - "list_id": { - "type": "string", - "format": "uuid", - "description": "Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign." + "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." + } + ] }, - "topic_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "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." + "stream_default": { + "description": "Make this the project's default identity for `stream`. Requires `stream`.", + "type": "boolean" } }, "required": [ - "name", - "subject", - "body", - "from", - "audience_type" + "domain" ], - "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`." + "type": "object" }, - "FilterConditionV1": { - "type": "object", + "DomainV1Deleted": { + "description": "Acknowledgement that a sending domain was removed.", "properties": { - "logic": { - "type": "string", + "deleted": { "enum": [ - "AND", - "OR" - ] + true + ], + "type": "boolean" }, - "groups": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilterGroupV1" - }, - "minItems": 1 + "id": { + "format": "uuid", + "type": "string" } }, "required": [ - "logic", - "groups" + "id", + "deleted" ], - "description": "A filter condition: one or more groups combined with `logic`." + "type": "object" }, - "FilterGroupV1": { - "type": "object", + "DomainV1List": { + "description": "Cursor-paginated list of sending domains.", "properties": { - "filters": { - "type": "array", + "data": { "items": { - "$ref": "#/components/schemas/SegmentFilterV1" - } + "$ref": "#/components/schemas/DomainV1" + }, + "type": "array" }, - "conditions": { - "$ref": "#/components/schemas/FilterConditionV1" + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "description": "Pass as `after` to fetch the next page. `null` on the last page.", + "type": [ + "string", + "null" + ] } }, "required": [ - "filters" + "data", + "has_more", + "next_cursor" ], - "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." + "type": "object" }, - "SegmentFilterV1": { - "type": "object", + "DomainVerificationStatus": { + "description": "Outcome of a verification check against SES.", "properties": { - "field": { - "type": "string", - "minLength": 1 + "dkim": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "name", + "value" + ], + "type": "object" + }, + "type": "array" }, - "operator": { - "type": "string", - "enum": [ - "equals", - "notEquals", - "contains", - "notContains", - "greaterThan", - "lessThan", - "greaterThanOrEqual", - "lessThanOrEqual", - "exists", - "notExists", - "within", - "olderThan", - "triggered", - "triggeredWithin", - "triggeredOlderThan", - "notTriggered", - "notTriggeredWithin", - "isMemberOf" + "mailFromDomain": { + "type": [ + "string", + "null" ] }, - "value": {}, - "unit": { - "type": "string", + "mailFromDomainStatus": { + "description": "SES custom MAIL FROM setup state. Only `Success` means SES is using it.", "enum": [ - "days", - "hours", - "minutes" + "Pending", + "Success", + "Failed", + "TemporaryFailure", + "NotConfigured", + null + ], + "type": [ + "string", + "null" ] + }, + "mxRecords": { + "items": { + "type": "string" + }, + "type": "array" + }, + "verified": { + "type": "boolean" } }, "required": [ - "field", - "operator" + "verified" ], - "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)." + "type": "object" }, - "CampaignV1Update": { - "type": "object", + "DraftMailboxMessage": { + "description": "Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "maxLength": 500 + "brief": { + "maxLength": 4000, + "type": "string" }, - "subject": { - "type": "string", - "minLength": 1 + "draft": { + "maxLength": 20000, + "type": "string" }, - "body": { - "type": "string", - "minLength": 1 + "instruction": { + "maxLength": 500, + "type": "string" }, - "from": { - "type": "string", - "format": "email", - "description": "Sender address. Its domain must be verified for this project." + "mode": { + "enum": [ + "draft", + "rewrite", + "subject" + ], + "type": "string" }, - "from_name": { + "recipientContext": { + "maxLength": 2000, + "type": "string" + }, + "senderAddress": { + "maxLength": 320, + "type": "string" + }, + "tone": { + "enum": [ + "friendly", + "neutral", + "formal", + "apologetic", + "direct" + ], + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "Email": { + "description": "A sent (or queued) transactional email.", + "properties": { + "bouncedAt": { + "description": "Bounced, or null.", + "format": "date-time", "type": [ "string", "null" - ], - "maxLength": 100 + ] }, - "reply_to": { + "clickedAt": { + "description": "First click, or null.", + "format": "date-time", "type": [ "string", "null" - ], - "format": "email" + ] }, - "email_category": { - "type": "string", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" + "clicks": { + "description": "Total clicks recorded.", + "type": "integer" + }, + "complainedAt": { + "description": "Spam complaint, or null.", + "format": "date-time", + "type": [ + "string", + "null" ] }, - "audience_type": { - "type": "string", - "enum": [ - "ALL", - "FILTERED", - "SEGMENT", - "LIST" + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" + }, + "deliveredAt": { + "description": "Accepted by the recipient's server, or null.", + "format": "date-time", + "type": [ + "string", + "null" ] }, - "audience_condition": { - "$ref": "#/components/schemas/FilterConditionV1" + "error": { + "type": [ + "string", + "null" + ] }, - "segment_id": { - "type": "string", - "format": "uuid" + "from": { + "type": "string" }, - "list_id": { - "type": "string", + "id": { "format": "uuid", - "description": "Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign." + "type": "string" }, - "topic_id": { + "openedAt": { + "description": "First open, or null.", + "format": "date-time", "type": [ "string", "null" - ], - "format": "uuid", - "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." - } - }, - "description": "Body for PATCH /api/v1/campaigns/{id}. All fields optional." - }, - "CampaignV1Deleted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "deleted": { - "type": "boolean", - "enum": [ - true ] - } - }, - "required": [ - "id", - "deleted" - ], - "description": "Acknowledgement that a campaign was deleted." - }, - "CampaignV1Send": { - "type": "object", - "properties": { - "scheduled_for": { - "type": "string", - "format": "date-time", - "description": "RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately." - } - }, - "description": "Body for POST /api/v1/campaigns/{id}/send." - }, - "CampaignV1Stats": { - "type": "object", - "properties": { - "total_recipients": { - "type": "integer" - }, - "sent": { - "type": "integer" }, - "delivered": { + "opens": { + "description": "Total opens recorded.", "type": "integer" }, - "opened": { - "type": "integer" + "projectId": { + "format": "uuid", + "type": "string" }, - "clicked": { - "type": "integer" + "sentAt": { + "description": "Handed to the provider, or null.", + "format": "date-time", + "type": [ + "string", + "null" + ] }, - "bounced": { - "type": "integer" + "status": { + "$ref": "#/components/schemas/EmailDeliveryStatus" }, - "open_rate": { - "type": "number" + "subject": { + "type": "string" }, - "click_rate": { - "type": "number" + "tags": { + "items": { + "type": "string" + }, + "type": "array" }, - "bounce_rate": { - "type": "number" + "to": { + "type": "string" }, - "delivery_rate": { - "type": "number" + "updatedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" } }, "required": [ - "total_recipients", - "sent", - "delivered", - "opened", - "clicked", - "bounced", - "open_rate", - "click_rate", - "bounce_rate", - "delivery_rate" + "id", + "projectId", + "from", + "to", + "subject", + "status", + "sentAt", + "deliveredAt", + "bouncedAt", + "openedAt", + "clickedAt", + "complainedAt", + "opens", + "clicks", + "tags", + "createdAt", + "updatedAt" ], - "description": "Materialized delivery and engagement counters for one campaign." + "type": "object" }, - "CampaignV1FailureList": { - "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": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CampaignV1Failure" - } - }, - "has_more": { - "type": "boolean" + "$ref": "#/components/schemas/EmailWithEvents" }, - "next_cursor": { - "type": [ - "string", - "null" + "success": { + "enum": [ + true ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." - }, - "total": { - "type": "integer", - "description": "Every FAILED row on this campaign, not just this page." + "type": "boolean" } }, "required": [ - "data", - "has_more", - "next_cursor", - "total" + "success", + "data" ], - "description": "Cursor-paginated list of a campaign's failed sends." + "type": "object" }, - "CampaignV1Failure": { - "type": "object", + "EmailEvent": { + "description": "One transition in a message's delivery history.", "properties": { "id": { - "type": "string", "format": "uuid", - "description": "Ledger row id. Pass the last one as `after` to page." - }, - "contact_id": { - "type": "string", - "format": "uuid" - }, - "email": { - "type": "string", - "description": "The recipient the send was for." - }, - "reason": { - "type": [ - "string", - "null" - ] + "type": "string" }, - "failed_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "contact_id", - "email", - "reason", - "failed_at" - ], - "description": "A campaign recipient whose send did not complete." - }, - "CampaignV1RetryFailed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" + "status": { + "$ref": "#/components/schemas/EmailDeliveryStatus" }, - "queued": { - "type": "integer", - "description": "How many FAILED rows the retry walk was started for, counted when it was queued." + "timestamp": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" } }, "required": [ "id", - "queued" + "status", + "timestamp" ], - "description": "Acknowledgement that a retry of a campaign's failed sends began." + "type": "object" }, - "SegmentV1List": { - "type": "object", + "EmailListResponse": { + "description": "Cursor-paginated list of emails.", "properties": { "data": { - "type": "array", "items": { - "$ref": "#/components/schemas/SegmentV1" - } - }, - "has_more": { - "type": "boolean" + "$ref": "#/components/schemas/Email" + }, + "type": "array" }, - "next_cursor": { + "nextCursor": { "type": [ "string", "null" + ] + }, + "success": { + "enum": [ + true ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + "type": "boolean" } }, "required": [ - "data", - "has_more", - "next_cursor" + "success", + "data" ], - "description": "Cursor-paginated list of segments." + "type": "object" }, - "SegmentV1": { - "type": "object", + "EmailResponse": { + "description": "A single email.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] + "data": { + "$ref": "#/components/schemas/Email" }, - "type": { - "type": "string", + "success": { "enum": [ - "DYNAMIC", - "STATIC" - ] - }, - "condition": { - "anyOf": [ - { - "$ref": "#/components/schemas/FilterConditionV1" - }, - { - "type": "null" - } - ] - }, - "track_membership": { + true + ], "type": "boolean" - }, - "member_count": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" } }, "required": [ - "id", - "name", - "description", - "type", - "condition", - "track_membership", - "member_count", - "created_at", - "updated_at" + "success", + "data" ], - "description": "A segment as exposed on the v1 API." + "type": "object" }, - "SegmentV1Create": { - "type": "object", + "EmailTestV1": { + "description": "Receipt for a sandbox test send.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "from": { + "description": "This project's sandbox sender — resolved server-side, never from the body.", + "format": "email", + "type": "string" }, - "description": { - "type": "string", - "maxLength": 500 + "id": { + "description": "The Email row this send created.", + "format": "uuid", + "type": "string" }, - "type": { - "type": "string", + "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": [ - "DYNAMIC", - "STATIC" + true ], - "default": "DYNAMIC" + "type": "boolean" }, - "condition": { - "$ref": "#/components/schemas/FilterConditionV1" + "status": { + "description": "Delivery status at the moment of the response — `PENDING` for a send still queued.", + "enum": [ + "PENDING", + "SENDING", + "SENT", + "DELIVERED", + "RECEIVED", + "BOUNCED", + "FAILED", + "REJECTED", + "RENDERING_FAILURE", + "DELIVERY_DELAY", + "CANCELLED" + ], + "type": "string" }, - "track_membership": { - "type": "boolean", - "default": false, - "description": "Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition." + "to": { + "description": "The recipient the message was queued for.", + "format": "email", + "type": "string" } }, "required": [ - "name" + "id", + "status", + "to", + "from", + "sandbox" ], - "description": "Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`." + "type": "object" }, - "SegmentV1Update": { - "type": "object", + "EmailV1": { + "description": "Receipt for a single transactional send.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "description": { - "type": "string", - "maxLength": 500 - }, - "condition": { - "$ref": "#/components/schemas/FilterConditionV1" + "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" }, - "track_membership": { - "type": "boolean" - } - }, - "description": "Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment." - }, - "SegmentV1Deleted": { - "type": "object", - "properties": { "id": { - "type": "string", - "format": "uuid" + "description": "The Email row this send created. Quote it in support requests.", + "format": "uuid", + "type": "string" }, - "deleted": { - "type": "boolean", + "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": [ - true - ] + "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", - "deleted" + "status", + "to", + "from" ], - "description": "Acknowledgement that a segment was deleted." + "type": "object" }, - "SegmentContactV1List": { - "type": "object", + "EmailValidationBatchRequestV1": { "properties": { - "data": { - "type": "array", + "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": { - "$ref": "#/components/schemas/SegmentContactV1" - } - }, - "has_more": { - "type": "boolean" - }, - "next_cursor": { - "type": [ - "string", - "null" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + "format": "email", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" } }, "required": [ - "data", - "has_more", - "next_cursor" + "emails" ], - "description": "Cursor-paginated list of the contacts belonging to a segment." + "type": "object" }, - "SegmentContactV1": { - "type": "object", + "EmailValidationBatchV1": { + "description": "One verdict per address, in the order they were given.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "email": { - "type": "string" - }, - "subscribed": { - "type": "boolean" - }, - "custom_fields": { - "type": "object", - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - }, - "created_at": { - "type": "string", - "format": "date-time" + "results": { + "items": { + "$ref": "#/components/schemas/EmailValidationV1" + }, + "type": "array" } }, "required": [ - "id", - "email", - "subscribed", - "custom_fields", - "created_at" + "results" ], - "description": "A contact belonging to a segment." + "type": "object" }, - "WorkflowV1List": { - "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": { - "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowV1" - } + "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" }, "has_more": { "type": "boolean" }, "next_cursor": { + "description": "Pass as `after` to fetch the next page. `null` on the last page.", "type": [ "string", "null" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + ] } }, "required": [ @@ -3072,377 +2874,266 @@ "has_more", "next_cursor" ], - "description": "Cursor-paginated list of workflows." + "type": "object" }, - "WorkflowV1": { - "type": "object", + "EmailValidationRunV1": { + "description": "One bulk validation run over a list.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "completed_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] }, - "name": { + "created_at": { + "format": "date-time", "type": "string" }, - "description": { + "deliverable_count": { + "type": "integer" + }, + "failure_reason": { + "description": "Set only on `failed`. Prose for an operator; never parse it.", "type": [ "string", "null" ] }, - "enabled": { - "type": "boolean" - }, - "trigger_type": { - "type": "string", - "enum": [ - "EVENT", - "MANUAL", - "SCHEDULE" - ] + "id": { + "type": "string" }, - "event_name": { + "list_id": { "type": [ "string", "null" - ], - "description": "Trigger event for `EVENT` workflows; null for the other trigger types." + ] }, - "allow_reentry": { - "type": "boolean" + "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" }, - "max_executions_per_hour": { + "risky_count": { + "type": "integer" + }, + "started_at": { + "format": "date-time", "type": [ - "integer", + "string", "null" ] }, - "version": { - "type": "integer", - "description": "Incremented on every structural (step/transition) change." - }, - "step_count": { - "type": "integer", - "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." - }, - "created_at": { - "type": "string", - "format": "date-time" + "status": { + "enum": [ + "pending", + "running", + "completed", + "failed" + ], + "type": "string" }, - "updated_at": { - "type": "string", - "format": "date-time" + "undeliverable_count": { + "type": "integer" } }, "required": [ "id", - "name", - "description", - "enabled", - "trigger_type", - "event_name", - "allow_reentry", - "max_executions_per_hour", - "version", - "step_count", - "created_at", - "updated_at" + "list_id", + "status", + "processed_count", + "deliverable_count", + "undeliverable_count", + "risky_count", + "started_at", + "completed_at", + "failure_reason", + "created_at" ], - "description": "An automation workflow as exposed on the v1 API." + "type": "object" }, - "WorkflowCreateV1": { - "type": "object", + "EmailValidationV1": { + "description": "One address's verdict, with the evidence behind it.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "maxLength": 1000 - }, - "trigger_type": { - "$ref": "#/components/schemas/WorkflowTriggerTypeV1" + "email": { + "type": "string" }, - "event_name": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "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." + "has_mx_records": { + "description": "The domain publishes MX records.", + "type": "boolean" }, - "interval_ms": { - "type": "integer", - "minimum": 60000, - "maximum": 2592000000, - "description": "For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour." + "is_disposable": { + "description": "A throwaway-inbox provider. The ONLY flag here that lowers the verdict.", + "type": "boolean" }, - "enabled": { - "type": "boolean", - "description": "Workflows are created disabled. A workflow can only be enabled once every step is configured." + "is_personal": { + "description": "A free/consumer provider (Gmail, Outlook). List-quality information, not a problem.", + "type": "boolean" }, - "allow_reentry": { + "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" }, - "sequence": { - "type": "array", + "reasons": { + "description": "Human-readable findings. Prose for a person to read — branch on `verdict`, never on these.", "items": { - "$ref": "#/components/schemas/WorkflowSequenceStepV1" + "type": "string" }, - "minItems": 1, - "maxItems": 199, - "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." + "type": "array" + }, + "verdict": { + "$ref": "#/components/schemas/EmailValidationVerdictV1" } }, "required": [ - "name" + "email", + "verdict", + "is_disposable", + "is_role_address", + "is_personal", + "has_mx_records", + "reasons" ], - "description": "Body for POST /api/v1/workflows." + "type": "object" }, - "WorkflowTriggerTypeV1": { - "type": "string", + "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": [ - "EVENT", - "MANUAL", - "SCHEDULE" + "deliverable", + "undeliverable", + "risky", + "unknown" ], - "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`." + "type": "string" }, - "WorkflowSequenceStepV1": { - "type": "object", - "properties": { - "type": { - "$ref": "#/components/schemas/WorkflowSequenceStepTypeV1" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Human-readable label, e.g. `Day 0: welcome`." + "EmailWithEvents": { + "allOf": [ + { + "$ref": "#/components/schemas/Email" }, - "config": { - "type": "object", - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + { + "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" + } }, - "description": "Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects." - }, - "template_id": { - "type": "string", - "format": "uuid", - "description": "For `SEND_EMAIL`: a template in this project." + "required": [ + "events" + ], + "type": "object" } - }, - "required": [ - "type", - "name", - "config" - ], - "description": "One step of a linear workflow sequence." - }, - "WorkflowSequenceStepTypeV1": { - "type": "string", - "enum": [ - "SEND_EMAIL", - "DELAY", - "WAIT_FOR_EVENT", - "CONDITION", - "EXIT", - "WEBHOOK", - "UPDATE_CONTACT", - "SEND_AT_OPTIMAL_TIME" ], - "description": "A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server." + "description": "A transactional email together with its delivery history." }, - "WorkflowUpdateV1": { - "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`.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "maxLength": 1000 - }, - "trigger_type": { - "allOf": [ - { - "$ref": "#/components/schemas/WorkflowTriggerTypeV1" + "error": { + "properties": { + "code": { + "type": "string" }, - { - "description": "What starts a workflow. `EVENT`: a custom event you record. `MANUAL`: only an explicit API call. `SCHEDULE`: a fixed repeating interval." + "details": { + "properties": { + "errors": { + "items": {}, + "type": "array" + } + }, + "required": [ + "errors" + ], + "type": "object" + }, + "message": { + "type": "string" } - ] - }, - "event_name": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "interval_ms": { - "type": "integer", - "minimum": 60000, - "maximum": 2592000000, - "description": "For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour." - }, - "enabled": { - "type": "boolean" - }, - "allow_reentry": { - "type": "boolean" - }, - "max_executions_per_hour": { - "type": [ - "integer", - "null" + }, + "required": [ + "message", + "code" ], - "exclusiveMinimum": 0, - "description": "Per-workflow start rate cap. `null` removes the cap." + "type": "object" }, - "sequence": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowSequenceStepV1" - }, - "minItems": 1, - "maxItems": 199, - "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." + "success": { + "enum": [ + false + ], + "type": "boolean" } }, - "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." + "required": [ + "error" + ], + "type": "object" }, - "WorkflowDeletedV1": { - "type": "object", + "EventNamesV1": { + "description": "Every distinct event name in the project, most frequent first.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "deleted": { - "type": "boolean", - "enum": [ - true - ] + "data": { + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ - "id", - "deleted" + "data" ], - "description": "Confirmation that a workflow was deleted." + "type": "object" }, - "WorkflowExecutionV1List": { - "type": "object", + "EventStatsV1": { + "description": "Per-name event counts over the applied window.", "properties": { "data": { - "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowExecutionV1" - } - }, - "has_more": { - "type": "boolean" - }, - "next_cursor": { - "type": [ - "string", - "null" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." - } - }, - "required": [ - "data", - "has_more", - "next_cursor" - ], - "description": "Cursor-paginated list of workflow executions, newest first." - }, - "WorkflowExecutionV1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "workflow_id": { - "type": "string", - "format": "uuid" - }, - "contact_id": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string", - "enum": [ - "RUNNING", - "WAITING", - "COMPLETED", - "EXITED", - "FAILED", - "CANCELLED" - ] - }, - "current_step_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "exit_reason": { - "type": [ - "string", - "null" - ] - }, - "started_at": { - "type": "string", - "format": "date-time" + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "count" + ], + "type": "object" + }, + "type": "array" }, - "completed_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "window": { + "$ref": "#/components/schemas/AnalyticsWindowV1" } }, "required": [ - "id", - "workflow_id", - "contact_id", - "status", - "current_step_id", - "exit_reason", - "started_at", - "completed_at" + "data", + "window" ], - "description": "One contact's run through a workflow." + "type": "object" }, - "WorkflowExecutionStartV1": { - "type": "object", + "EventTrackV1": { + "description": "Body for POST /api/v1/events.", "properties": { "contact_id": { - "type": "string", + "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", - "description": "Contact to enter the workflow. Must belong to this project." + "type": "string" }, - "context": { - "type": "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", @@ -3450,1455 +3141,899 @@ "object", "array", "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + ] }, - "description": "Extra variables merged into the contact's data for this run." + "description": "Arbitrary event payload.", + "type": "object" } }, "required": [ - "contact_id" + "name" ], - "description": "Body for POST /api/v1/workflows/{id}/executions." + "type": "object" }, - "WorkflowStatsV1": { - "type": "object", + "EventV1": { + "description": "A recorded custom event.", "properties": { - "workflow_id": { - "type": "string", - "format": "uuid" + "contact_id": { + "format": "uuid", + "type": [ + "string", + "null" + ] }, - "name": { + "created_at": { + "format": "date-time", "type": "string" }, - "enabled": { - "type": "boolean" - }, - "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." - } + "email_id": { + "format": "uuid", + "type": [ + "string", + "null" ] }, - "step_count": { - "type": "integer", - "description": "Steps in the workflow's graph, trigger step included." - }, - "total": { - "type": "integer" - }, - "by_status": { - "type": "object", - "additionalProperties": { - "type": "integer" - }, - "description": "Execution counts keyed by status; a status with no executions is absent." + "id": { + "format": "uuid", + "type": "string" }, - "completion_rate": { - "type": [ - "number", - "null" - ], - "description": "Completed ÷ finished executions (0–1). Null until at least one execution has finished." + "name": { + "type": "string" }, - "avg_duration_ms": { + "payload": { + "additionalProperties": {}, + "description": "The payload recorded with the event, or null.", "type": [ - "number", + "object", "null" ] - }, - "emails": { - "type": "object", - "properties": { - "sent": { - "type": "integer" - }, - "opened": { - "type": "integer" - }, - "clicked": { - "type": "integer" - } - }, - "required": [ - "sent", - "opened", - "clicked" - ] - }, - "conversions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "event_name": { - "type": "string" - }, - "count": { - "type": "integer" - } - }, - "required": [ - "goal_id", - "name", - "event_name", - "count" - ] - } } }, "required": [ - "workflow_id", + "id", "name", - "enabled", - "trigger_type", - "step_count", - "total", - "by_status", - "completion_rate", - "avg_duration_ms", - "emails", - "conversions" + "contact_id", + "email_id", + "payload", + "created_at" ], - "description": "Execution, email and conversion totals for one workflow." + "type": "object" }, - "WorkflowGraphV1": { - "type": "object", + "EventV1List": { + "description": "Cursor-paginated list of events, newest first.", "properties": { - "workflow_id": { - "type": "string", - "format": "uuid" + "data": { + "items": { + "$ref": "#/components/schemas/EventV1" + }, + "type": "array" }, - "version": { - "type": "integer", - "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." + "has_more": { + "type": "boolean" }, - "steps": { - "type": "array", + "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" + }, + "FilterConditionV1": { + "description": "A filter condition: one or more groups combined with `logic`.", + "properties": { + "groups": { "items": { - "$ref": "#/components/schemas/WorkflowStepReadV1" - } + "$ref": "#/components/schemas/FilterGroupV1" + }, + "minItems": 1, + "type": "array" }, - "transitions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowTransitionV1" - } + "logic": { + "enum": [ + "AND", + "OR" + ], + "type": "string" } }, "required": [ - "workflow_id", - "version", - "steps", - "transitions" + "logic", + "groups" ], - "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." + "type": "object" }, - "WorkflowStepReadV1": { - "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": { - "id": { - "type": "string", - "format": "uuid", - "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." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "conditions": { + "$ref": "#/components/schemas/FilterConditionV1" }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" - }, - "template_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." - }, - "type": { - "type": "string", - "enum": [ - "TRIGGER", - "SEND_EMAIL", - "DELAY", - "WAIT_FOR_EVENT", - "CONDITION", - "EXIT", - "WEBHOOK", - "UPDATE_CONTACT", - "SEND_AT_OPTIMAL_TIME" - ] - }, - "config": { - "type": "object", - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + "filters": { + "items": { + "$ref": "#/components/schemas/SegmentFilterV1" }, - "description": "The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase." + "type": "array" } }, "required": [ - "id", - "name", - "position", - "type", - "config" + "filters" ], - "description": "One node of a workflow graph, as read." + "type": "object" }, - "WorkflowStepPositionV1": { - "type": "object", + "IdResponse": { + "description": "Success envelope carrying the affected resource's id, e.g. after a delete.", "properties": { - "x": { - "type": "number" + "data": { + "properties": { + "id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" }, - "y": { - "type": "number" + "success": { + "enum": [ + true + ], + "type": "boolean" } }, "required": [ - "x", - "y" + "success", + "data" ], - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - }, - "description": "Where this step sits on the editor canvas." + "type": "object" }, - "WorkflowTransitionV1": { - "type": "object", + "ListSubscribe": { + "description": "Body for POST /api/lists/{id}/subscribe.", "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Caller-chosen on a write, exactly like a step id." - }, - "from_step_id": { - "type": "string", - "format": "uuid" - }, - "to_step_id": { - "type": "string", - "format": "uuid" + "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" }, - "condition": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], + "data": { "additionalProperties": {}, - "description": "Null to always follow this edge. From a `CONDITION` step, `{ \"branch\": \"yes\" }`, `{ \"branch\": \"no\" }`, or `{ \"branch\": \"\" }` in the multi form." + "description": "Custom fields to upsert onto the contact as part of subscribing.", + "type": "object" }, - "priority": { - "type": "integer", - "minimum": 0, - "description": "Evaluation order among the edges leaving one step; lowest first." + "email": { + "format": "email", + "type": "string" } }, "required": [ - "id", - "from_step_id", - "to_step_id", - "condition", - "priority" + "email" ], - "description": "One directed edge between two steps." + "type": "object" }, - "WorkflowGraphReplaceV1": { - "type": "object", + "ListSubscribeResponse": { + "description": "Result of a list-subscribe call.", "properties": { - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowStepV1" + "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" + }, + "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" + } }, - "minItems": 1, - "maxItems": 200, - "description": "The complete step set. Exactly one must be a `TRIGGER`." + "required": [ + "membershipId", + "status", + "created", + "previousStatus" + ], + "type": "object" }, - "transitions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowTransitionV1" - }, - "maxItems": 400, - "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." + "success": { + "enum": [ + true + ], + "type": "boolean" } }, "required": [ - "steps", - "transitions" + "success", + "data" ], - "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." + "type": "object" }, - "WorkflowStepV1": { - "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" + "ListUnsubscribe": { + "description": "Body for POST /api/lists/{id}/unsubscribe.", + "properties": { + "email": { + "format": "email", + "type": "string" } + }, + "required": [ + "email" ], - "discriminator": { - "propertyName": "type", - "mapping": { - "TRIGGER": "#/components/schemas/WorkflowTriggerStepV1", - "SEND_EMAIL": "#/components/schemas/WorkflowSendEmailStepV1", - "DELAY": "#/components/schemas/WorkflowDelayStepV1", - "WAIT_FOR_EVENT": "#/components/schemas/WorkflowWaitForEventStepV1", - "CONDITION": "#/components/schemas/WorkflowConditionStepV1", - "EXIT": "#/components/schemas/WorkflowExitStepV1", - "WEBHOOK": "#/components/schemas/WorkflowWebhookStepV1", - "UPDATE_CONTACT": "#/components/schemas/WorkflowUpdateContactStepV1", - "SEND_AT_OPTIMAL_TIME": "#/components/schemas/WorkflowSendAtOptimalTimeStepV1" + "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" } }, - "description": "One node of a workflow graph." + "required": [ + "success", + "data" + ], + "type": "object" }, - "WorkflowTriggerStepV1": { - "type": "object", + "ListV1": { + "description": "A subscriber list as exposed on the v1 API.", "properties": { - "id": { - "type": "string", + "confirmation_template_id": { "format": "uuid", - "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." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "type": [ + "string", + "null" + ] }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" + "created_at": { + "format": "date-time", + "type": "string" }, - "template_id": { + "description": { "type": [ "string", "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + ] }, - "type": { - "type": "string", - "enum": [ - "TRIGGER" - ] + "double_opt_in": { + "type": "boolean" }, - "config": { - "type": "object", - "properties": { - "eventName": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "intervalMs": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - } - }, - "required": [ - "id", - "name", - "position", - "type", - "config" - ], - "description": "The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`." - }, - "WorkflowSendEmailStepV1": { - "type": "object", - "properties": { "id": { - "type": "string", "format": "uuid", - "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." + "type": "string" }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "member_count": { + "description": "Memberships in ANY status, including PENDING and UNSUBSCRIBED ones.", + "type": "integer" }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" + "name": { + "type": "string" }, - "template_id": { + "redirect_url": { "type": [ "string", "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." - }, - "type": { - "type": "string", - "enum": [ - "SEND_EMAIL" ] }, - "config": { - "type": "object", - "properties": { - "templateId": { - "type": "string", - "format": "uuid" - }, - "subject": { - "type": "string", - "maxLength": 1000 - }, - "body": { - "type": "string" - }, - "recipient": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "CONTACT", - "CUSTOM" - ] - }, - "customEmail": { - "type": "string", - "format": "email" - } - }, - "required": [ - "type" - ], - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } + "updated_at": { + "format": "date-time", + "type": "string" } }, "required": [ "id", "name", - "position", - "type", - "config" + "description", + "double_opt_in", + "confirmation_template_id", + "redirect_url", + "member_count", + "created_at", + "updated_at" ], - "description": "Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`." + "type": "object" }, - "WorkflowDelayStepV1": { - "type": "object", + "ListV1Create": { + "description": "Body for POST /api/v1/lists.", "properties": { - "id": { - "type": "string", + "confirmation_template_id": { "format": "uuid", - "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." + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 500, + "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": { - "type": "string", + "maxLength": 200, "minLength": 1, - "maxLength": 100 - }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" + "type": "string" }, - "template_id": { + "redirect_url": { + "description": "Where a confirmed contact is sent after following the confirmation link.", + "format": "uri", "type": [ "string", "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." - }, - "type": { - "type": "string", - "enum": [ - "DELAY" ] - }, - "config": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "exclusiveMinimum": 0 - }, - "unit": { - "type": "string", - "enum": [ - "minutes", - "hours", - "days" - ] - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } } }, "required": [ - "id", - "name", - "position", - "type", - "config" + "name" ], - "description": "Pauses the run for `amount` × `unit`, up to 365 days." + "type": "object" }, - "WorkflowWaitForEventStepV1": { - "type": "object", + "ListV1Deleted": { + "description": "Acknowledgement that a list was deleted.", "properties": { + "deleted": { + "enum": [ + true + ], + "type": "boolean" + }, "id": { - "type": "string", "format": "uuid", - "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." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "type": "string" + } + }, + "required": [ + "id", + "deleted" + ], + "type": "object" + }, + "ListV1List": { + "description": "Cursor-paginated list of subscriber lists.", + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ListV1" + }, + "type": "array" }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" + "has_more": { + "type": "boolean" }, - "template_id": { + "next_cursor": { + "description": "Pass as `after` to fetch the next page. `null` on the last page.", "type": [ "string", "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." - }, - "type": { - "type": "string", - "enum": [ - "WAIT_FOR_EVENT" ] - }, - "config": { - "type": "object", - "properties": { - "eventName": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "timeout": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 31536000 - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } } }, "required": [ - "id", - "name", - "position", - "type", - "config" + "data", + "has_more", + "next_cursor" ], - "description": "Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass." + "type": "object" }, - "WorkflowConditionStepV1": { - "type": "object", + "ListV1Update": { + "description": "Body for PATCH /api/v1/lists/{id}.", "properties": { - "id": { - "type": "string", + "confirmation_template_id": { "format": "uuid", - "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." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" - }, - "template_id": { "type": [ "string", "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + ] }, - "type": { - "type": "string", - "enum": [ - "CONDITION" + "description": { + "maxLength": 500, + "type": [ + "string", + "null" ] }, - "config": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "enum": [ - "multi" - ] - }, - "field": { - "type": "string", - "minLength": 1 - }, - "operator": { - "type": "string", - "enum": [ - "equals", - "notEquals", - "contains", - "notContains", - "greaterThan", - "lessThan", - "greaterThanOrEqual", - "lessThanOrEqual", - "exists", - "notExists" - ] - }, - "value": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - }, - "branches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "name": { - "type": "string", - "minLength": 1 - }, - "operator": { - "type": "string", - "enum": [ - "equals", - "notEquals", - "contains", - "notContains", - "greaterThan", - "lessThan", - "greaterThanOrEqual", - "lessThanOrEqual", - "exists", - "notExists" - ] - }, - "value": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - }, - "required": [ - "id", - "name", - "operator" - ], - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - }, - "maxItems": 20 - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - } - }, - "required": [ - "id", - "name", - "position", - "type", - "config" - ], - "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." - }, - "WorkflowExitStepV1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "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." + "double_opt_in": { + "type": "boolean" }, "name": { - "type": "string", + "maxLength": 200, "minLength": 1, - "maxLength": 100 - }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" + "type": "string" }, - "template_id": { + "redirect_url": { + "format": "uri", "type": [ "string", "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." - }, - "type": { - "type": "string", - "enum": [ - "EXIT" ] - }, - "config": { - "type": "object", - "properties": { - "reason": { - "type": "string", - "maxLength": 200 - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } } }, - "required": [ - "id", - "name", - "position", - "type", - "config" - ], - "description": "Ends the run early and stamps `exit_reason`." + "type": "object" }, - "WorkflowWebhookStepV1": { - "type": "object", + "Mailbox": { + "description": "A receiving mailbox on one of the project's verified domains.", "properties": { - "id": { - "type": "string", - "format": "uuid", - "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." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "address": { + "description": "The full mailbox address, e.g. `support@superbooks.io`.", + "format": "email", + "type": "string" }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" + "createdAt": { + "format": "date-time", + "type": "string" }, - "template_id": { + "displayName": { "type": [ "string", "null" - ], + ] + }, + "domainId": { + "description": "The verified domain this mailbox lives on.", "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + "type": "string" }, - "type": { - "type": "string", - "enum": [ - "WEBHOOK" + "id": { + "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" ] }, - "config": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "method": { - "type": "string", - "enum": [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ] - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } + "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", - "name", - "position", - "type", - "config" + "address", + "displayName", + "status", + "quotaBytes", + "domainId", + "createdAt" ], - "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." + "type": "object" }, - "WorkflowUpdateContactStepV1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "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." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" - }, - "template_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." - }, - "type": { - "type": "string", - "enum": [ - "UPDATE_CONTACT" - ] + "MailboxDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Mailbox" }, - "config": { - "type": "object", + { "properties": { - "updates": { - "type": "object", - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - }, - "subscribed": { - "type": "boolean" + "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" } }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } + "required": [ + "settings" + ], + "type": "object" } - }, - "required": [ - "id", - "name", - "position", - "type", - "config" ], - "description": "Writes `updates` onto the contact, and optionally flips `subscribed`." + "description": "A mailbox plus its IMAP/SMTP connection settings." }, - "WorkflowSendAtOptimalTimeStepV1": { - "type": "object", + "Problem": { + "description": "RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface.", "properties": { - "id": { - "type": "string", - "format": "uuid", - "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." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "position": { - "$ref": "#/components/schemas/WorkflowStepPositionV1" - }, - "template_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type." + "code": { + "description": "Machine-readable lowercase error code, e.g. `scope_missing`.", + "type": "string" }, - "type": { - "type": "string", - "enum": [ - "SEND_AT_OPTIMAL_TIME" - ] + "detail": { + "description": "Explanation specific to this occurrence.", + "type": "string" }, - "config": { - "type": "object", - "properties": { - "templateId": { - "type": "string", - "format": "uuid" - }, - "fallbackHour": { - "type": "integer", - "minimum": 0, - "maximum": 23 + "errors": { + "description": "Field-level failures. Present on 422 `validation_error` responses.", + "items": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "pointer": { + "description": "RFC 6901 JSON Pointer to the offending field.", + "type": "string" + } }, - "maxDelayHours": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 168 - } - }, - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" + "required": [ + "pointer", + "code", + "message" ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } - } - }, - "required": [ - "id", - "name", - "position", - "type", - "config" - ], - "description": "Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`." - }, - "WorkflowCloneV1": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Name for the copy. Defaults to `Copy of `." - } - }, - "description": "Body for `POST /api/v1/workflows/{id}/clone`." - }, - "WorkflowStateChangeV1": { - "type": "object", - "properties": { - "workflow": { - "$ref": "#/components/schemas/WorkflowV1" + "type": "object" + }, + "type": "array" }, - "cancelled_executions": { - "type": "integer", - "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)." - } - }, - "required": [ - "workflow", - "cancelled_executions" - ], - "description": "The workflow after a pause or resume, with the number of runs the call stopped." - }, - "EmailV1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The Email row this send created. Quote it in support requests." + "instance": { + "description": "Request path the failure occurred on.", + "type": "string" + }, + "request_id": { + "description": "Correlation id — quote it in support requests.", + "type": "string" }, "status": { - "type": "string", - "enum": [ - "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "BOUNCED", - "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" - ], - "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." + "description": "HTTP status code, repeated in the body.", + "type": "integer" }, - "to": { - "type": "string", - "format": "email", - "description": "The recipient the message was queued for." + "title": { + "description": "Short, stable summary — the same for every occurrence of a `type`.", + "type": "string" }, - "from": { - "type": "string", - "format": "email", - "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." + "type": { + "description": "Dereferenceable URI identifying the error class, anchored on the docs errors page.", + "format": "uri", + "type": "string" } }, "required": [ - "id", + "type", + "title", "status", - "to", - "from" + "code" ], - "description": "Receipt for a single transactional send." + "type": "object" }, - "SendEmailV1": { - "type": "object", + "ProjectRecord": { "properties": { - "subject": { - "type": "string", - "minLength": 1, - "maxLength": 998, - "pattern": "^[^\\r\\n]*$" + "billingLimitCampaigns": { + "type": [ + "integer", + "null" + ] }, - "body": { - "type": "string", - "minLength": 1 + "billingLimitInbound": { + "type": [ + "integer", + "null" + ] }, - "template": { - "type": "string", - "format": "uuid" + "billingLimitTransactional": { + "type": [ + "integer", + "null" + ] }, - "subscribed": { - "type": "boolean" + "billingLimitWorkflows": { + "type": [ + "integer", + "null" + ] }, - "name": { + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": "string" }, - "from": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "object", - "properties": { - "name": { - "type": "string", - "pattern": "^[^\\r\\n]*$" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "email" - ] - } + "disabled": { + "type": "boolean" + }, + "disabledReason": { + "type": [ + "string", + "null" ] }, - "reply": { - "type": "string", - "format": "email" + "id": { + "format": "uuid", + "type": "string" }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 998, - "pattern": "^[^\\r\\n]*$" - } + "language": { + "description": "ISO 639-1 code for customer-facing content.", + "type": "string" }, - "data": { - "type": "object", - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + "name": { + "type": "string" }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[^\\r\\n\"]+$" - }, - "content": { - "type": "string", - "minLength": 1 - }, - "contentType": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "contentId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[^<>\\r\\n]+$" - }, - "disposition": { - "type": "string", - "enum": [ - "attachment", - "inline" - ], - "default": "attachment" - } - }, - "required": [ - "filename", - "content", - "contentType" - ] - }, - "maxItems": 10 + "organizationId": { + "type": [ + "string", + "null" + ] }, - "tags": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "maxItems": 10 + "sandboxHandle": { + "description": "Local-part of the sandbox quick-start sender; null until first derived.", + "type": [ + "string", + "null" + ] }, - "cc": { - "type": "array", - "items": { - "type": "string", - "format": "email" - } + "sesRegion": { + "type": [ + "string", + "null" + ] }, - "bcc": { - "type": "array", - "items": { - "type": "string", - "format": "email" - } + "stripeCustomerId": { + "type": [ + "string", + "null" + ] }, - "to": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "email" - ] - } + "stripeSubscriptionId": { + "type": [ + "string", + "null" + ] + }, + "tracking": { + "enum": [ + "ENABLED", + "DISABLED", + "MARKETING_ONLY" ], - "description": "The single recipient. Use `cc`/`bcc` to copy others on the same message." + "type": "string" + }, + "updatedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" } }, "required": [ - "to" + "id", + "name", + "disabled", + "disabledReason", + "sandboxHandle", + "stripeCustomerId", + "stripeSubscriptionId", + "billingLimitWorkflows", + "billingLimitCampaigns", + "billingLimitTransactional", + "billingLimitInbound", + "tracking", + "sesRegion", + "language", + "organizationId", + "createdAt", + "updatedAt" ], - "description": "Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient." + "type": "object" }, - "EmailTestV1": { - "type": "object", + "ProjectV1": { + "description": "The project the presented credential is scoped to.", "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "disabled": { + "description": "A disabled project sends nothing; every send is refused.", + "type": "boolean" + }, "id": { - "type": "string", "format": "uuid", - "description": "The Email row this send created." + "type": "string" }, - "status": { - "type": "string", - "enum": [ - "PENDING", - "SENDING", - "SENT", - "DELIVERED", - "RECEIVED", - "BOUNCED", - "FAILED", - "REJECTED", - "RENDERING_FAILURE", - "DELIVERY_DELAY", - "CANCELLED" - ], - "description": "Delivery status at the moment of the response — `PENDING` for a send still queued." + "language": { + "description": "ISO 639-1 code for customer-facing content.", + "type": "string" }, - "to": { - "type": "string", - "format": "email", - "description": "The recipient the message was queued for." + "name": { + "type": "string" }, - "from": { - "type": "string", - "format": "email", - "description": "This project's sandbox sender — resolved server-side, never from the body." + "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" + ] }, - "sandbox": { - "type": "boolean", + "ses_region": { + "description": "Locked once the first domain is added.", + "type": [ + "string", + "null" + ] + }, + "tracking": { "enum": [ - true + "ENABLED", + "DISABLED", + "MARKETING_ONLY" ], - "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." + "type": "string" } }, "required": [ "id", - "status", - "to", - "from", - "sandbox" + "name", + "disabled", + "sandbox_address", + "ses_region", + "tracking", + "language", + "created_at" ], - "description": "Receipt for a sandbox test send." + "type": "object" }, - "SendTestEmailV1": { - "type": "object", + "RecipientDomainStatsV1": { + "description": "Delivery outcomes for one recipient domain on one day.", "properties": { - "to": { - "type": "string", - "format": "email", - "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." + "bounced": { + "type": "integer" }, - "subject": { - "type": "string", - "minLength": 1, - "maxLength": 998 + "complained": { + "type": "integer" }, - "body": { - "type": "string", - "minLength": 1, - "description": "HTML body. Merge tags are rendered as on any other send." + "computed_at": { + "description": "When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly.", + "format": "date-time", + "type": "string" }, - "from": { - "type": "string", - "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." + "day": { + "description": "The UTC day these counts cover, as `YYYY-MM-DD`.", + "type": "string" + }, + "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" - ], - "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." + "domain", + "day", + "sent", + "delivered", + "bounced", + "complained", + "opened", + "computed_at" + ], + "type": "object" }, - "ContactV1List": { - "type": "object", + "RecipientDomainStatsV1List": { + "description": "Cursor-paginated recipient-domain rollup, newest day first.", "properties": { "data": { - "type": "array", "items": { - "$ref": "#/components/schemas/ContactV1" - } + "$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" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + ] } }, "required": [ @@ -4906,40 +4041,29 @@ "has_more", "next_cursor" ], - "description": "Cursor-paginated list of contacts." + "type": "object" }, - "ContactV1": { - "type": "object", + "SegmentContactV1": { + "description": "A contact belonging to a segment.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "email": { + "created_at": { + "format": "date-time", "type": "string" }, - "subscribed": { - "type": "boolean" - }, "custom_fields": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", + "type": "object" }, - "created_at": { - "type": "string", - "format": "date-time" + "email": { + "type": "string" }, - "updated_at": { - "type": "string", - "format": "date-time" + "id": { + "format": "uuid", + "type": "string" + }, + "subscribed": { + "type": "boolean" } }, "required": [ @@ -4947,135 +4071,216 @@ "email", "subscribed", "custom_fields", - "created_at", - "updated_at" + "created_at" ], - "description": "A contact as exposed on the v1 API." + "type": "object" }, - "ContactV1Create": { - "type": "object", + "SegmentContactV1List": { + "description": "Cursor-paginated list of the contacts belonging to a segment.", "properties": { - "email": { - "type": "string", - "format": "email" + "data": { + "items": { + "$ref": "#/components/schemas/SegmentContactV1" + }, + "type": "array" }, - "subscribed": { - "type": "boolean", - "default": true + "has_more": { + "type": "boolean" }, - "custom_fields": { + "next_cursor": { + "description": "Pass as `after` to fetch the next page. `null` on the last page.", "type": [ "string", - "number", - "boolean", - "object", - "array", "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`." + ] } }, "required": [ - "email" + "data", + "has_more", + "next_cursor" ], - "description": "Body for POST /api/v1/contacts." + "type": "object" }, - "ContactV1Update": { - "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).", "properties": { - "subscribed": { - "type": "boolean" + "field": { + "minLength": 1, + "type": "string" }, - "custom_fields": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" + "operator": { + "enum": [ + "equals", + "notEquals", + "contains", + "notContains", + "greaterThan", + "lessThan", + "greaterThanOrEqual", + "lessThanOrEqual", + "exists", + "notExists", + "within", + "olderThan", + "triggered", + "triggeredWithin", + "triggeredOlderThan", + "notTriggered", + "notTriggeredWithin", + "isMemberOf" ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - } + "type": "string" + }, + "unit": { + "enum": [ + "days", + "hours", + "minutes" + ], + "type": "string" + }, + "value": {} }, - "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." + "required": [ + "field", + "operator" + ], + "type": "object" }, - "ContactV1Deleted": { - "type": "object", + "SegmentV1": { + "description": "A segment as exposed on the v1 API.", "properties": { + "condition": { + "anyOf": [ + { + "$ref": "#/components/schemas/FilterConditionV1" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, "id": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" }, - "deleted": { - "type": "boolean", + "member_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "track_membership": { + "type": "boolean" + }, + "type": { "enum": [ - true - ] + "DYNAMIC", + "STATIC" + ], + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" } }, "required": [ "id", - "deleted" + "name", + "description", + "type", + "condition", + "track_membership", + "member_count", + "created_at", + "updated_at" ], - "description": "Acknowledgement that a contact was deleted." + "type": "object" }, - "ListSubscribe": { - "type": "object", + "SegmentV1Create": { + "description": "Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`.", "properties": { - "email": { - "type": "string", - "format": "email" + "condition": { + "$ref": "#/components/schemas/FilterConditionV1" }, - "data": { - "type": "object", - "additionalProperties": {}, - "description": "Custom fields to upsert onto the contact as part of subscribing." + "description": { + "maxLength": 500, + "type": "string" }, - "allowResubscribe": { - "type": "boolean", + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "track_membership": { "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." + "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" } }, "required": [ - "email" + "name" ], - "description": "Body for POST /api/lists/{id}/subscribe." + "type": "object" }, - "ListUnsubscribe": { - "type": "object", + "SegmentV1Deleted": { + "description": "Acknowledgement that a segment was deleted.", "properties": { - "email": { - "type": "string", - "format": "email" + "deleted": { + "enum": [ + true + ], + "type": "boolean" + }, + "id": { + "format": "uuid", + "type": "string" } }, "required": [ - "email" + "id", + "deleted" ], - "description": "Body for POST /api/lists/{id}/unsubscribe." + "type": "object" }, - "ListV1List": { - "type": "object", + "SegmentV1List": { + "description": "Cursor-paginated list of segments.", "properties": { "data": { - "type": "array", "items": { - "$ref": "#/components/schemas/ListV1" - } + "$ref": "#/components/schemas/SegmentV1" + }, + "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" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + ] } }, "required": [ @@ -5083,874 +4288,1094 @@ "has_more", "next_cursor" ], - "description": "Cursor-paginated list of subscriber lists." + "type": "object" }, - "ListV1": { - "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.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "condition": { + "$ref": "#/components/schemas/FilterConditionV1" }, - "name": { + "description": { + "maxLength": 500, "type": "string" }, - "description": { - "type": [ - "string", - "null" - ] + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" }, - "double_opt_in": { + "track_membership": { "type": "boolean" - }, - "confirmation_template_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "redirect_url": { - "type": [ - "string", - "null" - ] - }, - "member_count": { - "type": "integer", - "description": "Memberships in ANY status, including PENDING and UNSUBSCRIBED ones." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" } }, - "required": [ - "id", - "name", - "description", - "double_opt_in", - "confirmation_template_id", - "redirect_url", - "member_count", - "created_at", - "updated_at" - ], - "description": "A subscriber list as exposed on the v1 API." + "type": "object" }, - "ListV1Create": { - "type": "object", + "SendEmail": { + "description": "Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required.", "properties": { - "name": { - "type": "string", + "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, - "maxLength": 200 + "type": "string" }, - "description": { - "type": [ - "string", - "null" - ], - "maxLength": 500 + "cc": { + "items": { + "format": "email", + "type": "string" + }, + "type": "array" }, - "double_opt_in": { - "type": "boolean", - "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." + "data": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", + "type": "object" }, - "confirmation_template_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" + "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" }, - "redirect_url": { - "type": [ - "string", - "null" - ], - "format": "uri", - "description": "Where a confirmed contact is sent after following the confirmation link." - } - }, - "required": [ - "name" - ], - "description": "Body for POST /api/v1/lists." - }, - "ListV1Update": { - "type": "object", - "properties": { "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "type": "string" }, - "description": { - "type": [ - "string", - "null" - ], - "maxLength": 500 + "reply": { + "format": "email", + "type": "string" }, - "double_opt_in": { + "subject": { + "maxLength": 998, + "minLength": 1, + "pattern": "^[^\\r\\n]*$", + "type": "string" + }, + "subscribed": { "type": "boolean" }, - "confirmation_template_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" + "tags": { + "items": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + }, + "maxItems": 10, + "type": "array" }, - "redirect_url": { - "type": [ - "string", - "null" - ], - "format": "uri" + "template": { + "format": "uuid", + "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" + } + ] } }, - "description": "Body for PATCH /api/v1/lists/{id}." + "required": [ + "to" + ], + "type": "object" }, - "ListV1Deleted": { - "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`.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "emails": { + "items": { + "$ref": "#/components/schemas/SendEmailRecipientResult" + }, + "type": "array" }, - "deleted": { - "type": "boolean", - "enum": [ - true - ] + "timestamp": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" } }, "required": [ - "id", - "deleted" + "emails", + "timestamp" ], - "description": "Acknowledgement that a list was deleted." + "type": "object" }, - "AddDomainBody": { - "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": { - "projectId": { - "type": "string", - "format": "uuid" - }, - "domain": { - "type": "string", - "minLength": 3, - "maxLength": 253 - }, - "region": { - "type": "string", - "enum": [ - "us-east-1", - "us-west-2", - "eu-west-1" + "contact": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id", + "email" ], - "description": "Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region." + "type": "object" }, - "stream": { - "$ref": "#/components/schemas/SendingStream" - }, - "streamDefault": { - "type": "boolean", - "description": "Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it." + "email": { + "format": "uuid", + "type": "string" } }, "required": [ - "domain" + "contact", + "email" ], - "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." + "type": "object" }, - "SendingStream": { - "type": "string", - "enum": [ - "TRANSACTIONAL", - "MARKETING" - ], - "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." - }, - "AssignDomainStream": { - "type": "object", - "properties": { - "stream": { - "type": [ - "string", - "null" - ], - "enum": [ - "TRANSACTIONAL", - "MARKETING", - null - ], - "description": "Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address." - }, - "streamDefault": { - "type": "boolean", - "description": "Make this the project's default identity for its stream, demoting whichever held it." - }, - "defaultFromAddress": { - "type": [ - "string", - "null" - ], - "format": "email", - "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." - } - }, - "description": "Body for PATCH /api/domains/{id}." - }, - "DomainV1List": { - "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": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DomainV1" - } - }, - "has_more": { - "type": "boolean" + "$ref": "#/components/schemas/SendEmailData" }, - "next_cursor": { - "type": [ - "string", - "null" + "success": { + "enum": [ + true ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + "type": "boolean" } }, "required": [ - "data", - "has_more", - "next_cursor" + "success", + "data" ], - "description": "Cursor-paginated list of sending domains." + "type": "object" }, - "DomainV1": { - "type": "object", + "SendEmailV1": { + "description": "Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "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" }, - "domain": { + "bcc": { + "items": { + "format": "email", + "type": "string" + }, + "type": "array" + }, + "body": { + "minLength": 1, "type": "string" }, - "verified": { - "type": "boolean" + "cc": { + "items": { + "format": "email", + "type": "string" + }, + "type": "array" }, - "region": { - "type": [ - "string", - "null" - ] + "data": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", + "type": "object" }, - "stream": { - "allOf": [ + "from": { + "anyOf": [ { - "$ref": "#/components/schemas/SendingStream" + "format": "email", + "type": "string" }, { - "type": [ - "string", - "null" + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "name": { + "pattern": "^[^\\r\\n]*$", + "type": "string" + } + }, + "required": [ + "email" ], - "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": "object" } ] }, - "stream_default": { - "type": "boolean" + "headers": { + "additionalProperties": { + "maxLength": 998, + "pattern": "^[^\\r\\n]*$", + "type": "string" + }, + "type": "object" }, - "default_from_address": { - "type": [ - "string", - "null" - ] + "name": { + "type": "string" }, - "mail_from_domain": { - "type": [ - "string", - "null" - ] + "reply": { + "format": "email", + "type": "string" }, - "mail_from_domain_status": { - "type": [ - "string", - "null" - ], - "description": "SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address." + "subject": { + "maxLength": 998, + "minLength": 1, + "pattern": "^[^\\r\\n]*$", + "type": "string" }, - "dkim_verified": { + "subscribed": { "type": "boolean" }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "domain", - "verified", - "region", - "stream", - "stream_default", - "default_from_address", - "mail_from_domain", - "mail_from_domain_status", - "dkim_verified", - "created_at", - "updated_at" - ], - "description": "A sending domain as exposed on the v1 API." - }, - "DomainV1Create": { - "type": "object", - "properties": { - "domain": { - "type": "string", - "minLength": 3, - "maxLength": 253 + "tags": { + "items": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + }, + "maxItems": 10, + "type": "array" }, - "region": { - "type": "string", - "enum": [ - "us-east-1", - "us-west-2", - "eu-west-1" - ], - "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed." + "template": { + "format": "uuid", + "type": "string" }, - "stream": { - "allOf": [ + "to": { + "anyOf": [ { - "$ref": "#/components/schemas/SendingStream" + "format": "email", + "type": "string" }, { - "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." + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" } - ] - }, - "stream_default": { - "type": "boolean", - "description": "Make this the project's default identity for `stream`. Requires `stream`." + ], + "description": "The single recipient. Use `cc`/`bcc` to copy others on the same message." } }, "required": [ - "domain" + "to" ], - "description": "Body for POST /api/v1/domains." + "type": "object" }, - "DomainV1Deleted": { - "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": { - "id": { - "type": "string", - "format": "uuid" + "body": { + "description": "HTML body. Merge tags are rendered as on any other send.", + "minLength": 1, + "type": "string" }, - "deleted": { - "type": "boolean", - "enum": [ - true - ] + "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" + }, + "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", - "deleted" + "subject", + "body" ], - "description": "Acknowledgement that a sending domain was removed." + "type": "object" }, - "CreateTemplate": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "description": { - "type": "string", - "maxLength": 500 - }, - "subject": { - "type": "string", - "minLength": 1 - }, + "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": { "body": { - "type": "string", - "minLength": 1 + "description": "Template markup. Values it interpolates are escaped like any other.", + "type": "string" }, - "from": { - "type": "string", - "format": "email" + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" }, - "fromName": { + "description": { "type": [ "string", "null" - ], - "maxLength": 100 + ] }, - "replyTo": { - "type": [ - "string", - "null" - ], - "format": "email" + "id": { + "format": "uuid", + "type": "string" }, - "emailCategory": { - "type": "string", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" - ], - "default": "MARKETING" + "name": { + "description": "The literal identifier a template includes with `{{> name}}`.", + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "updatedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" } }, "required": [ + "id", + "projectId", "name", - "subject", "body", - "from" + "createdAt", + "updatedAt" ], - "description": "Body for POST /api/templates." + "type": "object" }, - "UpdateTemplate": { - "type": "object", + "SnippetListResponse": { + "description": "Cursor-paginated list of snippets.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "description": { - "type": "string", - "maxLength": 500 - }, - "subject": { - "type": "string", - "minLength": 1 - }, - "body": { - "type": "string", - "minLength": 1 - }, - "from": { - "type": "string", - "format": "email" - }, - "fromName": { - "type": [ - "string", - "null" - ], - "maxLength": 100 - }, - "replyTo": { - "type": [ - "string", - "null" + "data": { + "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" ], - "format": "email" + "type": "object" }, - "emailCategory": { - "type": "string", + "success": { "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" - ] + true + ], + "type": "boolean" } }, - "description": "Body for PATCH /api/templates/{id}." + "required": [ + "success", + "data" + ], + "type": "object" }, - "TemplateV1List": { - "type": "object", + "SuccessEmpty": { + "description": "Bare success envelope with no payload.", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TemplateV1" - } - }, - "has_more": { - "type": "boolean" - }, - "next_cursor": { - "type": [ - "string", - "null" + "success": { + "enum": [ + true ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + "type": "boolean" } }, "required": [ - "data", - "has_more", - "next_cursor" + "success" ], - "description": "Cursor-paginated list of templates." + "type": "object" }, - "TemplateV1": { - "type": "object", + "Suppression": { + "description": "A single suppressed-email record.", "properties": { + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, "id": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" }, - "name": { + "projectId": { + "format": "uuid", "type": "string" }, - "description": { - "type": [ - "string", - "null" - ] + "reason": { + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "type": "string" }, - "subject": { + "source": { + "enum": [ + "SES_WEBHOOK", + "API", + "DASHBOARD" + ], + "type": "string" + } + }, + "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" }, - "body": { + "reason": { + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], "type": "string" }, - "from": { + "source": { + "enum": [ + "SES_WEBHOOK", + "API", + "DASHBOARD" + ], "type": "string" }, - "from_name": { + "suppressed": { + "type": "boolean" + } + }, + "required": [ + "suppressed" + ], + "type": "object" + }, + "SuppressionListResponse": { + "description": "Cursor-paginated list of suppressions.", + "properties": { + "cursor": { "type": [ "string", "null" ] }, - "reply_to": { + "data": { + "items": { + "$ref": "#/components/schemas/Suppression" + }, + "type": "array" + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { "type": [ "string", "null" ] }, - "email_category": { - "type": "string", + "success": { "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" - ] - }, - "version": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" + true + ], + "type": "boolean" } }, "required": [ - "id", - "name", - "description", - "subject", - "body", - "from", - "from_name", - "reply_to", - "email_category", - "version", - "created_at", - "updated_at" + "success", + "data" ], - "description": "An email template as exposed on the v1 API." + "type": "object" }, - "TemplateV1Create": { - "type": "object", + "SuppressionV1": { + "description": "A suppressed address as exposed on the v1 API.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "description": { - "type": [ - "string", - "null" - ], - "maxLength": 500 + "created_at": { + "format": "date-time", + "type": "string" }, - "subject": { - "type": "string", - "minLength": 1 + "email": { + "type": "string" }, - "body": { - "type": "string", - "minLength": 1 + "reason": { + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "type": "string" }, - "from": { - "type": "string", + "source": { + "type": "string" + } + }, + "required": [ + "email", + "reason", + "source", + "created_at" + ], + "type": "object" + }, + "SuppressionV1Create": { + "description": "Body for POST /api/v1/suppressions.", + "properties": { + "email": { "format": "email", - "description": "Sender address. Its domain must be verified for this project." + "type": "string" }, - "from_name": { - "type": [ - "string", - "null" + "reason": { + "default": "MANUAL", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "SuppressionV1Deleted": { + "description": "Acknowledgement that an address was un-suppressed.", + "properties": { + "deleted": { + "enum": [ + true ], - "maxLength": 100 + "type": "boolean" }, - "reply_to": { + "email": { + "type": "string" + } + }, + "required": [ + "email", + "deleted" + ], + "type": "object" + }, + "SuppressionV1List": { + "description": "Cursor-paginated list of suppressed addresses.", + "properties": { + "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" - ], - "format": "email" - }, - "email_category": { - "type": "string", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" - ], - "default": "MARKETING" + ] } }, "required": [ - "name", - "subject", - "body", - "from" + "data", + "has_more", + "next_cursor" ], - "description": "Body for POST /api/v1/templates." + "type": "object" }, - "TemplateV1Update": { - "type": "object", + "Template": { + "description": "A reusable email template.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "body": { + "type": "string" + }, + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" }, "description": { "type": [ "string", "null" - ], - "maxLength": 500 - }, - "subject": { - "type": "string", - "minLength": 1 + ] }, - "body": { - "type": "string", - "minLength": 1 + "emailCategory": { + "enum": [ + "MARKETING", + "TRANSACTIONAL", + "SELF_MANAGED_UNSUBSCRIBE" + ], + "type": "string" }, "from": { - "type": "string", - "format": "email" + "format": "email", + "type": "string" }, - "from_name": { + "fromName": { "type": [ "string", "null" - ], - "maxLength": 100 + ] }, - "reply_to": { + "id": { + "format": "uuid", + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "replyTo": { + "format": "email", "type": [ "string", "null" - ], - "format": "email" - }, - "email_category": { - "type": "string", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" ] + }, + "subject": { + "type": "string" + }, + "updatedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" } }, - "description": "Body for PATCH /api/v1/templates/{id}." + "required": [ + "id", + "projectId", + "name", + "subject", + "body", + "from", + "emailCategory", + "createdAt", + "updatedAt" + ], + "type": "object" }, - "TemplateV1Deleted": { - "type": "object", + "TemplateListResponse": { + "description": "Cursor-paginated list of templates.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "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" }, - "deleted": { - "type": "boolean", + "success": { "enum": [ true - ] + ], + "type": "boolean" } }, "required": [ - "id", - "deleted" + "success", + "data" ], - "description": "Acknowledgement that a template was deleted." + "type": "object" }, - "CreateSnippet": { - "type": "object", + "TemplateV1": { + "description": "An email template as exposed on the v1 API.", "properties": { - "name": { - "type": "string", - "pattern": "^[a-z][\\da-z_-]{0,63}$/i" + "body": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" }, "description": { "type": [ "string", "null" + ] + }, + "email_category": { + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" ], - "maxLength": 500 + "type": "string" + }, + "from": { + "type": "string" + }, + "from_name": { + "type": [ + "string", + "null" + ] + }, + "id": { + "format": "uuid", + "type": "string" }, - "body": { - "type": "string", - "minLength": 1, - "maxLength": 20000 - } - }, - "required": [ - "name", - "body" - ], - "description": "Body for POST /api/snippets." - }, - "UpdateSnippet": { - "type": "object", - "properties": { "name": { - "type": "string", - "pattern": "^[a-z][\\da-z_-]{0,63}$/i" + "type": "string" }, - "description": { + "reply_to": { "type": [ "string", "null" - ], - "maxLength": 500 + ] }, - "body": { - "type": "string", - "minLength": 1, - "maxLength": 20000 + "subject": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "version": { + "type": "integer" } }, - "description": "Body for PATCH /api/snippets/{id}." - }, - "CreateWebhook": { - "type": "object", + "required": [ + "id", + "name", + "description", + "subject", + "body", + "from", + "from_name", + "reply_to", + "email_category", + "version", + "created_at", + "updated_at" + ], + "type": "object" + }, + "TemplateV1Create": { + "description": "Body for POST /api/v1/templates.", "properties": { - "url": { - "type": "string", - "format": "uri" + "body": { + "minLength": 1, + "type": "string" }, - "eventTypes": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" - ] - }, - "minItems": 1 + "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": [ - "url", - "eventTypes" + "name", + "subject", + "body", + "from" ], - "description": "Body for POST /api/webhooks — register a user webhook for one or more events." + "type": "object" }, - "UpdateWebhook": { - "type": "object", + "TemplateV1Deleted": { + "description": "Acknowledgement that a template was deleted.", "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "eventTypes": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" - ] - }, - "minItems": 1 - }, - "status": { - "type": "string", + "deleted": { "enum": [ - "ACTIVE", - "PAUSED", - "DISABLED" - ] + true + ], + "type": "boolean" + }, + "id": { + "format": "uuid", + "type": "string" } }, - "description": "Body for PATCH /api/webhooks/{id}." + "required": [ + "id", + "deleted" + ], + "type": "object" }, - "WebhookV1List": { - "type": "object", + "TemplateV1List": { + "description": "Cursor-paginated list of templates.", "properties": { "data": { - "type": "array", "items": { - "$ref": "#/components/schemas/WebhookV1" - } + "$ref": "#/components/schemas/TemplateV1" + }, + "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" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + ] } }, "required": [ @@ -5958,1091 +5383,1058 @@ "has_more", "next_cursor" ], - "description": "Cursor-paginated list of webhook endpoints." + "type": "object" }, - "WebhookV1": { - "type": "object", + "TemplateV1Update": { + "description": "Body for PATCH /api/v1/templates/{id}.", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "url": { + "body": { + "minLength": 1, "type": "string" }, - "event_types": { - "type": "array", - "items": { - "type": "string" - } + "description": { + "maxLength": 500, + "type": [ + "string", + "null" + ] }, - "status": { - "type": "string", + "email_category": { "enum": [ - "ACTIVE", - "PAUSED", - "DISABLED" + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" + ], + "type": "string" + }, + "from": { + "format": "email", + "type": "string" + }, + "from_name": { + "maxLength": 100, + "type": [ + "string", + "null" ] }, - "created_at": { - "type": "string", - "format": "date-time" + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" }, - "updated_at": { - "type": "string", - "format": "date-time" + "reply_to": { + "format": "email", + "type": [ + "string", + "null" + ] + }, + "subject": { + "minLength": 1, + "type": "string" } }, - "required": [ - "id", - "url", - "event_types", - "status", - "created_at", - "updated_at" - ], - "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." + "type": "object" }, - "WebhookV1Created": { - "type": "object", + "TopicCreateV1": { "properties": { - "webhook": { - "$ref": "#/components/schemas/WebhookV1" + "default_opt_in": { + "type": "boolean" }, - "secret": { - "type": "string", - "description": "The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again." + "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": [ - "webhook", - "secret" + "key", + "name" ], - "description": "A newly created webhook and its one-time signing secret." + "type": "object" }, - "WebhookV1Create": { - "type": "object", + "TopicListV1": { + "description": "One page of the subjects this project mails about.", "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "event_types": { - "type": "array", + "data": { "items": { - "type": "string", - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" - ] + "$ref": "#/components/schemas/TopicV1" }, - "minItems": 1 + "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": [ - "url", - "event_types" + "data", + "has_more", + "next_cursor" ], - "description": "Body for POST /api/v1/webhooks." + "type": "object" }, - "WebhookV1Update": { - "type": "object", + "TopicSubscribeV1": { "properties": { - "url": { - "type": "string", - "format": "uri" + "contact_id": { + "format": "uuid", + "type": "string" }, - "event_types": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "email.sent", - "email.delivered", - "email.opened", - "email.clicked", - "email.bounced", - "email.complained", - "email.failed", - "contact.created", - "contact.unsubscribed", - "contacts.bulk_created" - ] - }, - "minItems": 1 - }, - "status": { - "type": "string", - "enum": [ - "ACTIVE", - "PAUSED", - "DISABLED" - ] - } - }, - "description": "Body for PATCH /api/v1/webhooks/{id}." - }, - "WebhookV1Deleted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "deleted": { - "type": "boolean", - "enum": [ - true - ] + "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": [ - "id", - "deleted" + "contact_id", + "subscribed" ], - "description": "Acknowledgement that a webhook was deleted." + "type": "object" }, - "WebhookV1SecretRotated": { - "type": "object", - "properties": { - "secret": { - "type": "string", - "description": "The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again." - }, - "previous_secret_expires_at": { - "type": "string", - "format": "date-time", - "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." - } - }, - "required": [ - "secret", - "previous_secret_expires_at" + "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" ], - "description": "A freshly rotated signing secret, and the moment the outgoing one stops verifying." + "type": "string" }, - "CreateApiKeyBody": { - "type": "object", + "TopicSubscriptionV1": { "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 120 - }, - "legacyGrantPreset": { - "type": "string", - "enum": [ - "FULL", - "SENDING_ONLY" + "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" ] }, - "mode": { - "type": "string", - "enum": [ - "LIVE", - "TEST" - ], - "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." - }, - "scopes": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "emails:send", - "emails:read", - "contacts:read", - "contacts:write", - "campaigns:read", - "campaigns:write", - "segments:read", - "segments:write", - "workflows:read", - "workflows:write", - "templates:read", - "templates:write", - "domains:read", - "domains:write", - "webhooks:read", - "webhooks:write", - "suppression:read", - "suppression:write", - "analytics:read", - "usage:read", - "events:read", - "events:write", - "projects:read", - "projects:write", - "api-keys:read", - "api-keys:write", - "campaigns:send", - "mailboxes:read", - "mailboxes:write", - "emails:test", - "deliverability:read", - "mailboxes:send", - "validation:read", - "validation:write", - "topics:read", - "topics:write", - "lists:read", - "lists:write" - ] - }, - "description": "The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`." - }, - "domainId": { + "confirmed_at": { + "format": "date-time", "type": [ "string", "null" - ], - "format": "uuid" - } - }, - "required": [ - "name" - ] - }, - "AddSuppression": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" + ] }, - "reason": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ], - "default": "MANUAL" + "contact_id": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/TopicSubscriptionStatusV1" + }, + "topic_id": { + "type": "string" } }, "required": [ - "email" + "topic_id", + "contact_id", + "status", + "confirmed_at", + "confirmation_url" ], - "description": "Body for POST /api/suppression — manually add an email to the suppression list." + "type": "object" }, - "SuppressionV1List": { - "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": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SuppressionV1" - } + "archived": { + "type": "boolean" }, - "has_more": { + "default_opt_in": { "type": "boolean" }, - "next_cursor": { + "description": { + "maxLength": 1000, "type": [ "string", "null" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + ] + }, + "name": { + "maxLength": 200, + "minLength": 1, + "type": "string" } }, - "required": [ - "data", - "has_more", - "next_cursor" - ], - "description": "Cursor-paginated list of suppressed addresses." + "type": "object" }, - "SuppressionV1": { - "type": "object", + "TopicV1": { + "description": "One subject this project mails about.", "properties": { - "email": { + "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" }, - "reason": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" + "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" ] }, - "source": { + "id": { "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time" + "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": [ - "email", - "reason", - "source", + "id", + "key", + "name", + "description", + "default_opt_in", + "archived", + "subscribed_count", + "unsubscribed_count", "created_at" ], - "description": "A suppressed address as exposed on the v1 API." + "type": "object" }, - "SuppressionV1Create": { - "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": { - "type": "string", - "format": "email" + "format": "email", + "type": "string" }, - "reason": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ], - "default": "MANUAL" + "event": { + "minLength": 1, + "type": "string" + }, + "subscribed": { + "type": "boolean" } }, "required": [ + "event", "email" ], - "description": "Body for POST /api/v1/suppressions." + "type": "object" }, - "SuppressionV1Deleted": { - "type": "object", + "TrackEventResponse": { + "description": "Response from POST /api/track.", "properties": { - "email": { - "type": "string" - }, - "deleted": { - "type": "boolean", - "enum": [ - true - ] - } - }, - "required": [ - "email", - "deleted" - ], - "description": "Acknowledgement that an address was un-suppressed." - }, - "TrackEvent": { - "type": "object", - "properties": { - "event": { - "type": "string", - "minLength": 1 - }, - "email": { - "type": "string", - "format": "email" + "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" }, - "subscribed": { + "success": { + "enum": [ + true + ], "type": "boolean" - }, - "data": { - "type": "object", - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." } }, "required": [ - "event", - "email" + "success", + "data" ], - "description": "Body for POST /api/track — record a custom event for a contact." + "type": "object" }, - "EventV1List": { - "type": "object", + "UpdateContactBody": { + "description": "Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses.", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventV1" - } + "customFields": { + "additionalProperties": {}, + "type": "object" }, - "has_more": { + "subscribed": { "type": "boolean" + } + }, + "type": "object" + }, + "UpdateSnippet": { + "description": "Body for PATCH /api/snippets/{id}.", + "properties": { + "body": { + "maxLength": 20000, + "minLength": 1, + "type": "string" }, - "next_cursor": { + "description": { + "maxLength": 500, "type": [ "string", "null" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + ] + }, + "name": { + "pattern": "^[a-z][\\da-z_-]{0,63}$/i", + "type": "string" } }, - "required": [ - "data", - "has_more", - "next_cursor" - ], - "description": "Cursor-paginated list of events, newest first." + "type": "object" }, - "EventV1": { - "type": "object", + "UpdateTemplate": { + "description": "Body for PATCH /api/templates/{id}.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "body": { + "minLength": 1, + "type": "string" }, - "name": { + "description": { + "maxLength": 500, "type": "string" }, - "contact_id": { - "type": [ - "string", - "null" + "emailCategory": { + "enum": [ + "TRANSACTIONAL", + "MARKETING", + "SELF_MANAGED_UNSUBSCRIBE" ], - "format": "uuid" + "type": "string" }, - "email_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" + "from": { + "format": "email", + "type": "string" }, - "payload": { + "fromName": { + "maxLength": 100, "type": [ - "object", + "string", "null" - ], - "additionalProperties": {}, - "description": "The payload recorded with the event, or null." + ] }, - "created_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "name", - "contact_id", - "email_id", - "payload", - "created_at" - ], - "description": "A recorded custom event." - }, - "EventTrackV1": { - "type": "object", - "properties": { "name": { - "type": "string", + "maxLength": 100, "minLength": 1, - "maxLength": 200, - "description": "Event name, e.g. `user.signup`." + "type": "string" }, - "contact_id": { - "type": "string", - "format": "uuid", - "description": "Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event." + "replyTo": { + "format": "email", + "type": [ + "string", + "null" + ] }, - "payload": { - "type": "object", - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "object", - "array", - "null" - ], - "additionalProperties": {}, - "description": "Arbitrary JSON value (string, number, boolean, null, array, or object)." - }, - "description": "Arbitrary event payload." + "subject": { + "minLength": 1, + "type": "string" } }, - "required": [ - "name" - ], - "description": "Body for POST /api/v1/events." + "type": "object" }, - "EventNamesV1": { - "type": "object", + "UpdateWebhook": { + "description": "Body for PATCH /api/webhooks/{id}.", "properties": { - "data": { - "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" - } - } - }, - "required": [ - "data" - ], - "description": "Every distinct event name in the project, most frequent first." - }, - "EventStatsV1": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - } - }, - "required": [ - "name", - "count" - ] - } + }, + "minItems": 1, + "type": "array" }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" - } - }, - "required": [ - "data", - "window" - ], - "description": "Per-name event counts over the applied window." - }, - "AnalyticsWindowV1": { - "type": "object", - "properties": { - "from": { - "type": "string", - "format": "date-time" + "status": { + "enum": [ + "ACTIVE", + "PAUSED", + "DISABLED" + ], + "type": "string" }, - "to": { - "type": "string", - "format": "date-time" + "url": { + "format": "uri", + "type": "string" } }, - "required": [ - "from", - "to" - ], - "description": "The time range this response was computed over, after the 90-day clamp." + "type": "object" }, - "AnalyticsTimeseriesV1": { - "type": "object", + "UsageV1": { + "description": "Current email usage against the limits that are actually enforced.", "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "date": { - "type": "string", - "format": "date-time" - }, - "emails": { - "type": "integer" - }, - "delivered": { - "type": "integer" - }, - "opens": { - "type": "integer" - }, - "clicks": { - "type": "integer" - }, - "bounces": { - "type": "integer" - } + "daily": { + "properties": { + "emails_sent": { + "description": "Today's sends. Null when the counter could not be read.", + "type": [ + "integer", + "null" + ] }, - "required": [ - "date", - "emails", - "delivered", - "opens", - "clicks", - "bounces" - ] - } - }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" + "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": [ - "data", - "window" + "plan", + "monthly", + "daily" ], - "description": "Daily email counters across the window. Every day in range is present, zero-filled." + "type": "object" }, - "AnalyticsCampaignStatsV1": { - "type": "object", + "VerifyEmail": { + "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc.", "properties": { - "total": { - "type": "integer" - }, - "active": { - "type": "integer", - "description": "Campaigns in DRAFT or SCHEDULED." - }, - "completed": { - "type": "integer" - }, - "average_open_rate": { - "type": "number", - "description": "Percentage, one decimal place." - }, - "average_click_rate": { - "type": "number" - }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" + "email": { + "format": "email", + "type": "string" } }, "required": [ - "total", - "active", - "completed", - "average_open_rate", - "average_click_rate", - "window" + "email" ], - "description": "Campaign counters and engagement over the window." + "type": "object" }, - "AnalyticsTopCampaignsV1": { - "type": "object", + "VerifyEmailResponse": { + "description": "Response from POST /api/verify — outcome of the syntax/MX/disposable check.", "properties": { "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "subject": { - "type": "string" - }, - "sent": { - "type": "integer" - }, - "opened": { - "type": "integer" - }, - "clicked": { - "type": "integer" - }, - "open_rate": { - "type": "number" - }, - "click_rate": { - "type": "number" - } + "additionalProperties": {}, + "properties": { + "email": { + "format": "email", + "type": "string" }, - "required": [ - "id", - "subject", - "sent", - "opened", - "clicked", - "open_rate", - "click_rate" - ] - } + "reason": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + }, + "required": [ + "email", + "valid" + ], + "type": "object" }, - "window": { - "$ref": "#/components/schemas/AnalyticsWindowV1" + "success": { + "enum": [ + true + ], + "type": "boolean" } }, "required": [ - "data", - "window" + "success", + "data" ], - "description": "Sent campaigns ranked by open rate." + "type": "object" }, - "DeliverabilityDiagnosisV1": { - "type": "object", + "Webhook": { + "description": "A user-managed outbound webhook.", "properties": { - "domain": { + "consecutiveFailures": { + "type": "integer" + }, + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": "string" }, - "address": { + "disabledAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", "type": [ "string", "null" ] }, - "checked_at": { - "type": "string", - "format": "date-time" + "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" }, - "identity": { - "$ref": "#/components/schemas/DeliverabilityIdentityV1" + "id": { + "format": "uuid", + "type": "string" }, - "suppression": { - "$ref": "#/components/schemas/DeliverabilitySuppressionV1" + "lastFour": { + "type": "string" }, - "recent_delivery": { - "$ref": "#/components/schemas/DeliverabilityRecentDeliveryV1" + "projectId": { + "format": "uuid", + "type": "string" }, - "findings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DeliverabilityFindingV1" - }, - "description": "What is wrong, worst first. An empty array means nothing here explains a delivery problem." + "status": { + "enum": [ + "ACTIVE", + "PAUSED", + "DISABLED" + ], + "type": "string" + }, + "updatedAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" } }, "required": [ - "domain", - "address", - "checked_at", - "identity", - "suppression", - "recent_delivery", - "findings" + "id", + "projectId", + "url", + "eventTypes", + "status", + "consecutiveFailures", + "createdAt", + "updatedAt" ], - "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." + "type": "object" }, - "DeliverabilityIdentityV1": { - "type": "object", + "WebhookCall": { + "description": "An attempted webhook delivery.", "properties": { - "registered": { - "type": "boolean", - "description": "Whether this project has a domain record at all. False makes every other field null." + "attempt": { + "type": "integer" }, - "verified": { - "type": "boolean" + "createdAt": { + "description": "ISO 8601 datetime string", + "format": "date-time", + "type": "string" }, - "dkim_status": { - "type": [ - "string", - "null" - ], - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "description": "DKIM signing. This is the one that decides whether Sendly will send from the domain at all." + "eventType": { + "type": "string" }, - "spf_status": { - "type": [ - "string", - "null" - ], - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "description": "SPF alignment for the sending identity." + "id": { + "format": "uuid", + "type": "string" }, - "dmarc_status": { - "type": [ - "string", - "null" - ], - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "description": "The DMARC policy published at `_dmarc.`." + "payload": { + "additionalProperties": {}, + "type": "object" }, - "mx_status": { + "responseBody": { "type": [ "string", "null" - ], - "enum": [ - "NOT_CHECKED", - "PENDING", - "VERIFIED", - "FAILED", - null - ], - "description": "Inbound receiving only. Null unless the domain has receiving enabled." + ] }, - "mail_from_domain": { + "responseStatus": { "type": [ - "string", + "integer", "null" ] }, - "mail_from_domain_status": { - "type": [ - "string", - "null" + "status": { + "enum": [ + "PENDING", + "SUCCESS", + "FAILED" ], - "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" }, - "last_checked_at": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup." + "webhookId": { + "format": "uuid", + "type": "string" } }, "required": [ - "registered", - "verified", - "dkim_status", - "spf_status", - "dmarc_status", - "mx_status", - "mail_from_domain", - "mail_from_domain_status", - "last_checked_at" + "id", + "webhookId", + "eventType", + "payload", + "status", + "attempt", + "createdAt" ], - "description": "The sending identity's DNS health, as last refreshed." + "type": "object" }, - "DeliverabilitySuppressionV1": { - "type": [ - "object", - "null" - ], + "WebhookCallsListResponse": { + "description": "Cursor-paginated list of recent calls for a single webhook.", "properties": { - "suppressed": { - "type": "boolean" - }, - "reason": { + "cursor": { "type": [ "string", "null" - ], - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE", - null ] }, - "source": { + "data": { + "items": { + "$ref": "#/components/schemas/WebhookCall" + }, + "type": "array" + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { "type": [ "string", "null" - ], - "enum": [ - "SES_WEBHOOK", - "API", - "DASHBOARD", - null ] }, - "suppressed_at": { - "type": [ - "string", - "null" + "success": { + "enum": [ + true ], - "format": "date-time" + "type": "boolean" } }, "required": [ - "suppressed", - "reason", - "source", - "suppressed_at" + "success", + "data" ], - "description": "Null unless the request named an `address`." + "type": "object" }, - "DeliverabilityRecentDeliveryV1": { - "type": "object", + "WebhookCreateResponse": { + "description": "Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely.", "properties": { - "window_days": { - "type": "integer" - }, - "scope": { - "type": "string", - "enum": [ - "project" + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/Webhook" + }, + { + "properties": { + "secret": { + "description": "Plaintext shared secret. Returned ONCE on create.", + "type": "string" + } + }, + "required": [ + "secret" + ], + "type": "object" + } ], - "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." - }, - "sent": { - "type": "integer" - }, - "delivered": { - "type": "integer" - }, - "bounced": { - "type": "integer" - }, - "complained": { - "type": "integer" - }, - "failed": { - "type": "integer" + "description": "A user-managed outbound webhook." }, - "bounce_rate": { - "type": [ - "number", - "null" + "success": { + "enum": [ + true ], - "description": "Bounced ÷ sent (0–1), or null when nothing was sent in the window." - }, - "complaint_rate": { - "type": [ - "number", - "null" - ] + "type": "boolean" } }, "required": [ - "window_days", - "scope", - "sent", - "delivered", - "bounced", - "complained", - "failed", - "bounce_rate", - "complaint_rate" + "success", + "data" ], - "description": "Delivery outcomes over the requested window." + "type": "object" }, - "DeliverabilityFindingV1": { - "type": "object", + "WebhookGetResponse": { + "description": "Single webhook (no secret).", "properties": { - "code": { - "type": "string", - "description": "Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`." - }, - "severity": { - "$ref": "#/components/schemas/DeliverabilityFindingSeverityV1" + "data": { + "$ref": "#/components/schemas/Webhook" }, - "summary": { - "type": "string", - "description": "What is wrong, in one sentence." - }, - "remedy": { - "type": "string", - "description": "What to do about it." + "success": { + "enum": [ + true + ], + "type": "boolean" } }, "required": [ - "code", - "severity", - "summary", - "remedy" - ], - "description": "One diagnosed problem, with its fix." - }, - "DeliverabilityFindingSeverityV1": { - "type": "string", - "enum": [ - "blocking", - "degraded", - "info" + "success", + "data" ], - "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." + "type": "object" }, - "RecipientDomainStatsV1List": { - "type": "object", + "WebhookListResponse": { + "description": "List of webhooks for the auth'd project.", "properties": { "data": { - "type": "array", "items": { - "$ref": "#/components/schemas/RecipientDomainStatsV1" - } + "$ref": "#/components/schemas/Webhook" + }, + "type": "array" }, - "has_more": { + "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" }, - "next_cursor": { - "type": [ - "string", - "null" + "success": { + "enum": [ + true ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + "type": "boolean" } }, "required": [ - "data", - "has_more", - "next_cursor" + "success", + "data" ], - "description": "Cursor-paginated recipient-domain rollup, newest day first." + "type": "object" }, - "RecipientDomainStatsV1": { - "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": { - "domain": { - "type": "string", - "description": "The recipient's domain, lowercased: the part after the `@`." + "created_at": { + "format": "date-time", + "type": "string" }, - "day": { - "type": "string", - "description": "The UTC day these counts cover, as `YYYY-MM-DD`." + "event_types": { + "items": { + "type": "string" + }, + "type": "array" }, - "sent": { - "type": "integer" + "id": { + "format": "uuid", + "type": "string" }, - "delivered": { - "type": "integer" + "status": { + "enum": [ + "ACTIVE", + "PAUSED", + "DISABLED" + ], + "type": "string" }, - "bounced": { - "type": "integer" + "updated_at": { + "format": "date-time", + "type": "string" }, - "complained": { - "type": "integer" + "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" }, - "opened": { - "type": "integer" + "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" }, - "computed_at": { - "type": "string", - "format": "date-time", - "description": "When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly." + "webhook": { + "$ref": "#/components/schemas/WebhookV1" } }, "required": [ - "domain", - "day", - "sent", - "delivered", - "bounced", - "complained", - "opened", - "computed_at" + "webhook", + "secret" ], - "description": "Delivery outcomes for one recipient domain on one day." + "type": "object" }, - "DmarcReportV1List": { - "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": { - "type": "array", "items": { - "$ref": "#/components/schemas/DmarcReportV1" - } + "$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" - ], - "description": "Pass as `after` to fetch the next page. `null` on the last page." + ] } }, "required": [ @@ -7050,1151 +6442,1938 @@ "has_more", "next_cursor" ], - "description": "Cursor-paginated DMARC aggregate reports, newest window first." + "type": "object" }, - "DmarcReportV1": { - "type": "object", + "WebhookV1SecretRotated": { + "description": "A freshly rotated signing secret, and the moment the outgoing one stops verifying.", "properties": { - "id": { + "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" }, - "report_id": { - "type": "string", - "description": "The receiver's own id for this report." - }, - "org_name": { - "type": "string", - "description": "The reporting receiver, e.g. `google.com`." - }, - "policy_domain": { - "type": "string", - "description": "The domain of yours the report is about." - }, - "range_begin": { - "type": "string", - "format": "date-time" - }, - "range_end": { - "type": "string", - "format": "date-time" - }, - "total_count": { - "type": "integer" - }, - "pass_count": { - "type": "integer", - "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." - }, - "fail_count": { - "type": "integer" - }, - "sources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "source_ip": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "disposition": { - "type": "string" - }, - "dkim": { - "type": "string" - }, - "spf": { - "type": "string" - }, - "header_from": { - "type": "string" - } - }, - "required": [ - "source_ip", - "count", - "disposition", - "dkim", - "spf", - "header_from" - ] - }, - "description": "Per-sending-source rows, as the receiver reported them." - }, - "received_at": { - "type": "string", - "format": "date-time" + "secret": { + "description": "The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again.", + "type": "string" } }, "required": [ - "id", - "report_id", - "org_name", - "policy_domain", - "range_begin", - "range_end", - "total_count", - "pass_count", - "fail_count", - "sources", - "received_at" + "secret", + "previous_secret_expires_at" ], - "description": "One DMARC aggregate (RUA) report." + "type": "object" }, - "UsageV1": { - "type": "object", + "WebhookV1Update": { + "description": "Body for PATCH /api/v1/webhooks/{id}.", "properties": { - "plan": { - "type": "string", + "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": [ - "free", - "pro", - "custom" + "ACTIVE", + "PAUSED", + "DISABLED" ], - "description": "`custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`." + "type": "string" }, - "monthly": { - "type": "object", + "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": { - "emails_sent": { - "type": "integer" - }, - "limit": { - "type": [ - "integer", - "null" - ], - "description": "Monthly cap on the total. Null when per-category limits govern instead." - }, - "categories": { - "type": "object", - "properties": { - "transactional": { - "type": "object", - "properties": { - "emails_sent": { - "type": "integer" - }, - "limit": { - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "emails_sent", - "limit" + "branches": { + "items": { + "additionalProperties": { + "additionalProperties": {}, + "description": "Arbitrary JSON value (string, number, boolean, null, array, or object).", + "type": [ + "string", + "number", + "boolean", + "object", + "array", + "null" ] }, - "campaign": { - "type": "object", - "properties": { - "emails_sent": { - "type": "integer" - }, - "limit": { - "type": [ - "integer", - "null" - ] - } + "properties": { + "id": { + "minLength": 1, + "type": "string" }, - "required": [ - "emails_sent", - "limit" - ] - }, - "workflow": { - "type": "object", - "properties": { - "emails_sent": { - "type": "integer" - }, - "limit": { - "type": [ - "integer", - "null" - ] - } + "name": { + "minLength": 1, + "type": "string" }, - "required": [ - "emails_sent", - "limit" - ] - }, - "inbound": { - "type": "object", - "properties": { - "emails_sent": { - "type": "integer" - }, - "limit": { - "type": [ - "integer", - "null" - ] - } + "operator": { + "enum": [ + "equals", + "notEquals", + "contains", + "notContains", + "greaterThan", + "lessThan", + "greaterThanOrEqual", + "lessThanOrEqual", + "exists", + "notExists" + ], + "type": "string" }, - "required": [ - "emails_sent", - "limit" - ] - } + "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" }, - "required": [ - "transactional", - "campaign", - "workflow", - "inbound" - ] - } - }, - "required": [ - "emails_sent", - "limit", - "categories" - ] - }, - "daily": { - "type": "object", - "properties": { - "emails_sent": { - "type": [ - "integer", - "null" - ], - "description": "Today's sends. Null when the counter could not be read." + "maxItems": 20, + "type": "array" }, - "limit": { - "type": "integer" + "field": { + "minLength": 1, + "type": "string" }, - "trust_tier": { - "type": "string", + "mode": { "enum": [ - "NEW", - "ESTABLISHED", - "TRUSTED" + "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" ] } }, - "required": [ - "emails_sent", - "limit", - "trust_tier" + "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": [ - "plan", - "monthly", - "daily" + "workflow_id", + "name", + "enabled", + "trigger_type", + "step_count", + "total", + "by_status", + "completion_rate", + "avg_duration_ms", + "emails", + "conversions" ], - "description": "Current email usage against the limits that are actually enforced." + "type": "object" }, - "ProjectV1": { - "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": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "disabled": { - "type": "boolean", - "description": "A disabled project sends nothing; every send is refused." - }, - "sandbox_address": { - "type": [ - "string", - "null" - ], - "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." - }, - "ses_region": { - "type": [ - "string", - "null" - ], - "description": "Locked once the first domain is added." - }, - "tracking": { - "type": "string", - "enum": [ - "ENABLED", - "DISABLED", - "MARKETING_ONLY" - ] - }, - "language": { - "type": "string", - "description": "ISO 639-1 code for customer-facing content." + "x": { + "type": "number" }, - "created_at": { - "type": "string", - "format": "date-time" + "y": { + "type": "number" } }, "required": [ - "id", - "name", - "disabled", - "sandbox_address", - "ses_region", - "tracking", - "language", - "created_at" + "x", + "y" ], - "description": "The project the presented credential is scoped to." + "type": "object" }, - "Mailbox": { - "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": { - "type": "string", - "format": "uuid" + "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" }, - "address": { - "type": "string", - "format": "email", - "description": "The full mailbox address, e.g. `support@superbooks.io`." + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" }, - "displayName": { + "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" ] }, - "status": { - "type": "string", + "type": { "enum": [ - "PROVISIONING", - "ACTIVE", - "SUSPENDED", - "FAILED" - ], - "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." - }, - "quotaBytes": { - "type": [ - "number", - "null" + "TRIGGER", + "SEND_EMAIL", + "DELAY", + "WAIT_FOR_EVENT", + "CONDITION", + "EXIT", + "WEBHOOK", + "UPDATE_CONTACT", + "SEND_AT_OPTIMAL_TIME" ], - "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." - }, - "domainId": { - "type": "string", - "format": "uuid", - "description": "The verified domain this mailbox lives on." - }, - "createdAt": { - "type": "string", - "format": "date-time" + "type": "string" } }, "required": [ "id", - "address", - "displayName", - "status", - "quotaBytes", - "domainId", - "createdAt" + "name", + "position", + "type", + "config" ], - "description": "A receiving mailbox on one of the project's verified domains." + "type": "object" }, - "MailboxDetail": { - "allOf": [ - { - "$ref": "#/components/schemas/Mailbox" + "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": [ { - "type": "object", - "properties": { - "settings": { - "type": "object", - "properties": { - "imap": { - "type": "object", - "properties": { - "host": { - "type": "string" - }, - "port": { - "type": "integer" - }, - "security": { - "type": "string", - "description": "Transport security, e.g. `SSL/TLS`." - }, - "username": { - "type": "string", - "description": "The mailbox address — it is also the login." - } - }, - "required": [ - "host", - "port", - "security", - "username" - ] - }, - "smtp": { - "type": "object", - "properties": { - "host": { - "type": "string" - }, - "port": { - "type": "integer" - }, - "security": { - "type": "string", - "description": "Transport security, e.g. `SSL/TLS`." - }, - "username": { - "type": "string", - "description": "The mailbox address — it is also the login." - } - }, - "required": [ - "host", - "port", - "security", - "username" - ] - } - }, - "required": [ - "imap", - "smtp" - ], - "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." - } - }, - "required": [ - "settings" - ] - } - ], - "description": "A mailbox plus its IMAP/SMTP connection settings." - }, - "CreateMailboxBody": { - "type": "object", - "properties": { - "projectId": { - "type": "string", - "format": "uuid", - "description": "Defaults to the project the credential resolves to. Naming a different one is refused." - }, - "domainId": { - "type": "string", - "format": "uuid", - "description": "A VERIFIED domain belonging to this project." - }, - "localPart": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "description": "The part before the `@`, e.g. `support`. Lowercased server-side." + "$ref": "#/components/schemas/WorkflowTriggerStepV1" }, - "displayName": { - "type": "string", - "maxLength": 120 + { + "$ref": "#/components/schemas/WorkflowSendEmailStepV1" }, - "quotaBytes": { - "type": "integer", - "exclusiveMinimum": 0, - "description": "NOT IMPLEMENTED — sending any value answers 400." - } - }, - "required": [ - "domainId", - "localPart" - ] - }, - "DraftMailboxMessage": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "enum": [ - "draft", - "rewrite", - "subject" - ] + { + "$ref": "#/components/schemas/WorkflowDelayStepV1" }, - "brief": { - "type": "string", - "maxLength": 4000 + { + "$ref": "#/components/schemas/WorkflowWaitForEventStepV1" }, - "draft": { - "type": "string", - "maxLength": 20000 + { + "$ref": "#/components/schemas/WorkflowConditionStepV1" }, - "instruction": { - "type": "string", - "maxLength": 500 + { + "$ref": "#/components/schemas/WorkflowExitStepV1" }, - "tone": { - "type": "string", - "enum": [ - "friendly", - "neutral", - "formal", - "apologetic", - "direct" - ] + { + "$ref": "#/components/schemas/WorkflowWebhookStepV1" }, - "recipientContext": { - "type": "string", - "maxLength": 2000 + { + "$ref": "#/components/schemas/WorkflowUpdateContactStepV1" }, - "senderAddress": { - "type": "string", - "maxLength": 320 + { + "$ref": "#/components/schemas/WorkflowSendAtOptimalTimeStepV1" } - }, - "required": [ - "mode" - ], - "description": "Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending." + ] }, - "ComposeMailboxMessage": { - "type": "object", + "WorkflowTransitionV1": { + "description": "One directed edge between two steps.", "properties": { - "to": { - "type": "array", - "items": { - "type": "string", - "format": "email" - }, - "minItems": 1, - "maxItems": 20 + "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" + ] }, - "cc": { - "type": "array", - "items": { - "type": "string", - "format": "email" - }, - "maxItems": 20 + "from_step_id": { + "format": "uuid", + "type": "string" }, - "bcc": { - "type": "array", - "items": { - "type": "string", - "format": "email" - }, - "maxItems": 20 + "id": { + "description": "Caller-chosen on a write, exactly like a step id.", + "format": "uuid", + "type": "string" }, - "subject": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "priority": { + "description": "Evaluation order among the edges leaving one step; lowest first.", + "minimum": 0, + "type": "integer" }, - "body": { - "type": "string", - "minLength": 1, - "maxLength": 50000 + "to_step_id": { + "format": "uuid", + "type": "string" } }, "required": [ - "to", - "subject", - "body" + "id", + "from_step_id", + "to_step_id", + "condition", + "priority" ], - "description": "Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox." + "type": "object" }, - "AppPassword": { - "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": { - "type": "string", - "format": "uuid" + "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": { - "type": "string", - "description": "What the credential is for, e.g. `Thunderbird on my laptop`." - }, - "scopes": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "imap", - "smtp" - ] - }, - "description": "Which protocols this password may authenticate. `imap` reads, `smtp` sends." + "maxLength": 100, + "minLength": 1, + "type": "string" }, - "lastFour": { - "type": "string", - "description": "The last four characters of the secret — enough to tell two credentials apart, and nothing more." + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" }, - "lastUsedAt": { + "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" - ], - "format": "date-time", - "description": "Null until a mail client has authenticated with it at least once." + ] }, - "createdAt": { - "type": "string", - "format": "date-time" + "type": { + "enum": [ + "TRIGGER" + ], + "type": "string" } }, "required": [ "id", "name", - "scopes", - "lastFour", - "lastUsedAt", - "createdAt" + "position", + "type", + "config" ], - "description": "An IMAP/SMTP credential for one mailbox, described but never reproduced." + "type": "object" }, - "AppPasswordReveal": { - "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": { - "type": "string", - "format": "uuid" + "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" }, - "revealUrl": { - "type": "string", - "format": "uri", - "description": "A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it." + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" }, - "revealExpiresAt": { - "type": "string", - "format": "date-time", - "description": "When the link stops working. Five minutes after creation; the password itself does not expire." + "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", - "revealUrl", - "revealExpiresAt" + "name", + "position", + "type", + "config" ], - "description": "A newly created app password, handed over as a one-time link rather than as a secret." + "type": "object" }, - "CreateAppPassword": { - "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": { - "type": "string", + "maxLength": 200, "minLength": 1, - "maxLength": 60 + "type": "string" }, - "scopes": { - "type": "array", + "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": { - "type": "string", - "enum": [ - "imap", - "smtp" - ] + "$ref": "#/components/schemas/WorkflowSequenceStepV1" }, + "maxItems": 199, "minItems": 1, - "default": [ - "imap", - "smtp" - ] - } - }, - "required": [ - "name" - ], - "description": "Body for POST /api/mailboxes/:id/app-passwords." - }, - "VerifyEmail": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" + "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." + } + ] } }, - "required": [ - "email" - ], - "description": "Body for POST /api/verify — validate email syntax, MX, disposable, etc." + "type": "object" }, - "TopicListV1": { - "type": "object", + "WorkflowV1": { + "description": "An automation workflow as exposed on the v1 API.", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TopicV1" - } + "allow_reentry": { + "type": "boolean" }, - "cursor": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "description": { "type": [ "string", "null" ] }, - "has_more": { + "enabled": { "type": "boolean" - } - }, - "required": [ - "data", - "cursor", - "has_more" - ] - }, - "TopicV1": { - "type": "object", - "properties": { - "id": { - "type": "string" }, - "key": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9][a-z0-9_-]*$", - "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." + "event_name": { + "description": "Trigger event for `EVENT` workflows; null for the other trigger types.", + "type": [ + "string", + "null" + ] }, - "name": { + "id": { + "format": "uuid", "type": "string" }, - "description": { + "max_executions_per_hour": { "type": [ - "string", + "integer", "null" ] }, - "default_opt_in": { - "type": "boolean", - "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`." + "name": { + "type": "string" }, - "archived": { - "type": "boolean", - "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." + "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" }, - "subscribed_count": { - "type": "integer", - "description": "Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`." + "trigger_type": { + "enum": [ + "EVENT", + "MANUAL", + "SCHEDULE" + ], + "type": "string" }, - "unsubscribed_count": { - "type": "integer" + "updated_at": { + "format": "date-time", + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time" + "version": { + "description": "Incremented on every structural (step/transition) change.", + "type": "integer" } }, "required": [ "id", - "key", "name", "description", - "default_opt_in", - "archived", - "subscribed_count", - "unsubscribed_count", - "created_at" + "enabled", + "trigger_type", + "event_name", + "allow_reentry", + "max_executions_per_hour", + "version", + "step_count", + "created_at", + "updated_at" ], - "description": "One subject this project mails about." + "type": "object" }, - "TopicCreateV1": { - "type": "object", + "WorkflowV1List": { + "description": "Cursor-paginated list of workflows.", "properties": { - "key": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9][a-z0-9_-]*$", - "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." + "data": { + "items": { + "$ref": "#/components/schemas/WorkflowV1" + }, + "type": "array" }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "has_more": { + "type": "boolean" }, - "description": { + "next_cursor": { + "description": "Pass as `after` to fetch the next page. `null` on the last page.", "type": [ "string", "null" - ], - "maxLength": 1000 - }, - "default_opt_in": { - "type": "boolean" + ] } }, "required": [ - "key", - "name" - ] + "data", + "has_more", + "next_cursor" + ], + "type": "object" }, - "TopicUpdateV1": { - "type": "object", + "WorkflowWaitForEventStepV1": { + "description": "Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass.", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": [ - "string", - "null" - ], - "maxLength": 1000 - }, - "default_opt_in": { - "type": "boolean" + "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" }, - "archived": { - "type": "boolean" - } - }, - "description": "`key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them." - }, - "TopicSubscriptionV1": { - "type": "object", - "properties": { - "topic_id": { + "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" }, - "contact_id": { + "name": { + "maxLength": 100, + "minLength": 1, "type": "string" }, - "status": { - "$ref": "#/components/schemas/TopicSubscriptionStatusV1" - }, - "confirmed_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" }, - "confirmation_url": { + "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" - ], - "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." - } - }, - "required": [ - "topic_id", - "contact_id", - "status", - "confirmed_at", - "confirmation_url" - ] - }, - "TopicSubscriptionStatusV1": { - "type": "string", - "enum": [ - "pending", - "subscribed", - "unsubscribed" - ], - "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." - }, - "TopicSubscribeV1": { - "type": "object", - "properties": { - "contact_id": { - "type": "string", - "format": "uuid" - }, - "subscribed": { - "type": "boolean", - "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." - } - }, - "required": [ - "contact_id", - "subscribed" - ] - }, - "ContactTopicPreferencesV1": { - "type": "object", - "properties": { - "contact_id": { - "type": "string" - }, - "subscribed": { - "type": "boolean", - "description": "The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say." + ] }, - "topics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "topic_id": { - "type": "string" - }, - "key": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9][a-z0-9_-]*$", - "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." - }, - "name": { - "type": "string" - }, - "subscribed": { - "type": "boolean", - "description": "The EFFECTIVE answer: what the send path concludes for this contact today." - }, - "pending": { - "type": "boolean" - } - }, - "required": [ - "topic_id", - "key", - "name", - "subscribed", - "pending" - ] - } - } - }, - "required": [ - "contact_id", - "subscribed", - "topics" - ], - "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." - }, - "EmailValidationBatchV1": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EmailValidationV1" - } + "type": { + "enum": [ + "WAIT_FOR_EVENT" + ], + "type": "string" } }, "required": [ - "results" + "id", + "name", + "position", + "type", + "config" ], - "description": "One verdict per address, in the order they were given." + "type": "object" }, - "EmailValidationV1": { - "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": { - "email": { - "type": "string" - }, - "verdict": { - "$ref": "#/components/schemas/EmailValidationVerdictV1" + "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" }, - "is_disposable": { - "type": "boolean", - "description": "A throwaway-inbox provider. The ONLY flag here that lowers the verdict." + "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" }, - "is_role_address": { - "type": "boolean", - "description": "The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them." + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" }, - "is_personal": { - "type": "boolean", - "description": "A free/consumer provider (Gmail, Outlook). List-quality information, not a problem." + "position": { + "$ref": "#/components/schemas/WorkflowStepPositionV1" }, - "has_mx_records": { - "type": "boolean", - "description": "The domain publishes MX records." + "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" + ] }, - "reasons": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Human-readable findings. Prose for a person to read — branch on `verdict`, never on these." + "type": { + "enum": [ + "WEBHOOK" + ], + "type": "string" } }, "required": [ - "email", - "verdict", - "is_disposable", - "is_role_address", - "is_personal", - "has_mx_records", - "reasons" - ], - "description": "One address's verdict, with the evidence behind it." - }, - "EmailValidationVerdictV1": { - "type": "string", - "enum": [ - "deliverable", - "undeliverable", - "risky", - "unknown" + "id", + "name", + "position", + "type", + "config" ], - "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`." + "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" }, - "EmailValidationBatchRequestV1": { - "type": "object", - "properties": { - "emails": { - "type": "array", - "items": { - "type": "string", - "format": "email" + "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" }, - "minItems": 1, - "maxItems": 50, - "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." + "tokenUrl": "https://app.sendly.now/api/auth/oauth2/token" } }, - "required": [ - "emails" - ] + "type": "oauth2" }, - "EmailValidationRunV1": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "list_id": { - "type": [ - "string", - "null" - ] + "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" + } }, - "status": { - "type": "string", - "enum": [ - "pending", - "running", - "completed", - "failed" - ] + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" + } }, - "processed_count": { - "type": "integer", - "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." + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + } }, - "deliverable_count": { - "type": "integer" + { + "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" }, - "undeliverable_count": { - "type": "integer" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, - "risky_count": { - "type": "integer" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unauthorized — missing or invalid auth" }, - "started_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "completed_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, - "failure_reason": { - "type": [ - "string", - "null" - ], - "description": "Set only on `failed`. Prose for an operator; never parse it." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Rate limit or billing limit exceeded" }, - "created_at": { - "type": "string", - "format": "date-time" + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal server error" } }, - "required": [ - "id", - "list_id", - "status", - "processed_count", - "deliverable_count", - "undeliverable_count", - "risky_count", - "started_at", - "completed_at", - "failure_reason", - "created_at" + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "contacts:read" + ] + } ], - "description": "One bulk validation run over a list." + "summary": "List contacts", + "tags": [ + "Contacts" + ] }, - "EmailValidationResultListV1": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/EmailValidationV1" - }, - { - "type": "object", + "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": { - "contact_id": { - "type": [ - "string", - "null" - ] + "data": { + "$ref": "#/components/schemas/Contact" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" } }, "required": [ - "contact_id" - ] + "success", + "data" + ], + "type": "object" } - ], - "description": "One address's verdict, with the evidence behind it." - } + } + }, + "description": "Contact created" }, - "cursor": { - "type": [ - "string", - "null" - ], - "description": "Pass as `cursor` for the next page; null on the last." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, - "has_more": { - "type": "boolean" + "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" } }, - "required": [ - "data", - "cursor", - "has_more" - ], - "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." - } - }, - "parameters": {} - }, - "paths": { - "/api/v1/campaigns": { - "get": { - "operationId": "v1ListCampaigns", - "tags": [ - "Campaigns" - ], - "summary": "List campaigns", - "description": "Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\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.", "security": [ { "ApiKeyAuth": [] @@ -8204,104 +8383,125 @@ }, { "OAuth2": [ - "campaigns:read" + "contacts:write" ] } ], - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - } - ], + "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": { - "description": "Campaign list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1List" + "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": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "post": { - "operationId": "v1CreateCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Create a campaign", - "description": "Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything.\n\nThe `from` address is checked against this project's verified domains before the campaign is written, so a campaign never exists with a sender it cannot use.\n\n`segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + }, "security": [ { "ApiKeyAuth": [] @@ -8311,126 +8511,146 @@ }, { "OAuth2": [ - "campaigns:write" + "contacts:write" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." - }, - "required": false, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", - "name": "Idempotency-Key", - "in": "header" - } - ], + "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": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Create" + "$ref": "#/components/schemas/ContactBulkCreateBody" } } - } + }, + "required": true }, "responses": { - "201": { - "description": "Campaign created", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" - } - } - } - }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + "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" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "404": { - "description": "`resource_not_found` — `segment_id` names a segment that does not belong to this project.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "409": { - "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "422": { - "description": "`validation_error` — the request did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. A key names ONE request, so it is never silently served another one's result: send a new key.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/campaigns/{id}": { - "get": { - "operationId": "v1GetCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Retrieve a campaign", - "description": "Fetch one campaign, including its materialized delivery counters.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.", + }, "security": [ { "ApiKeyAuth": [] @@ -8440,103 +8660,117 @@ }, { "OAuth2": [ - "campaigns:read" + "contacts:write" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" - } - ], + "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": { - "description": "The campaign", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "properties": { + "data": { + "$ref": "#/components/schemas/Contact" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Contact created or updated" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "patch": { - "operationId": "v1UpdateCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Update a campaign", - "description": "Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400.\n\nChanging `from` re-verifies the sender domain. Changing the audience on a `DRAFT` campaign schedules a recipient recount, so `stats.total_recipients` converges shortly after the response.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + }, "security": [ { "ApiKeyAuth": [] @@ -8546,123 +8780,103 @@ }, { "OAuth2": [ - "campaigns:write" + "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": { - "type": "string", "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CampaignV1Update" - } + "type": "string" } } - }, + ], "responses": { "200": { - "description": "The updated campaign", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "$ref": "#/components/schemas/IdResponse" } } - } + }, + "description": "Contact deleted" }, "400": { - "description": "`validation_error` — the campaign is not in an editable status, or the segment change is not allowed.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "delete": { - "operationId": "v1DeleteCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Delete a campaign", - "description": "Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + }, "security": [ { "ApiKeyAuth": [] @@ -8672,115 +8886,116 @@ }, { "OAuth2": [ - "campaigns:write" + "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": { - "type": "string", "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "type": "string" + } } ], "responses": { "200": { - "description": "Campaign deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Deleted" + "properties": { + "data": { + "$ref": "#/components/schemas/Contact" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Contact" }, "400": { - "description": "`validation_error` — only `DRAFT` campaigns can be deleted.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/campaigns/{id}/send": { - "post": { - "operationId": "v1SendCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Send or schedule a campaign", - "description": "Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now.\n\n**Send an `Idempotency-Key`.** This is the operation that cannot be undone: a retry without one starts a second fan-out over the same audience. The key is scoped to this campaign, so the same key on a different campaign is a `422 idempotency_key_reused` rather than a replay of the first campaign's response.\n\nAnswers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit.\n\nRequires the `campaigns:send` scope — Send or schedule your campaigns to their audience.", + }, "security": [ { "ApiKeyAuth": [] @@ -8790,147 +9005,136 @@ }, { "OAuth2": [ - "campaigns:send" + "contacts:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." - }, - "required": false, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", - "name": "Idempotency-Key", - "in": "header" + "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": { - "required": false, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Send" + "$ref": "#/components/schemas/UpdateContactBody" } } - } + }, + "required": true }, "responses": { "200": { - "description": "The campaign, now `SENDING` or `SCHEDULED`", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "properties": { + "data": { + "$ref": "#/components/schemas/Contact" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Updated contact" }, "400": { - "description": "`validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "409": { - "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "422": { - "description": "`validation_error` — the request did not match the schema; or `idempotency_key_reused` — this `Idempotency-Key` was already spent on a request with a different body. A key names ONE request, so it is never silently served another one's result: send a new key.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/campaigns/{id}/cancel": { - "post": { - "operationId": "v1CancelCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Cancel a campaign", - "description": "Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body.\n\nLike every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + }, "security": [ { "ApiKeyAuth": [] @@ -8940,115 +9144,82 @@ }, { "OAuth2": [ - "campaigns:write" + "contacts:write" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" - } - ], + "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": { - "description": "The cancelled campaign", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "$ref": "#/components/schemas/DomainListResponse" } } - } + }, + "description": "Domain list" }, "400": { - "description": "`validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/campaigns/{id}/pause": { - "post": { - "operationId": "v1PauseCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Pause a sending campaign", - "description": "Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + }, "security": [ { "ApiKeyAuth": [] @@ -9058,115 +9229,115 @@ }, { "OAuth2": [ - "campaigns:write" + "domains:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" - } - ], + "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": { - "200": { - "description": "The paused campaign", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "properties": { + "data": { + "$ref": "#/components/schemas/Domain" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Domain added" }, "400": { - "description": "`validation_error` — only a `SENDING` campaign can be paused.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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." } - } - } - }, - "/api/v1/campaigns/{id}/resume": { - "post": { - "operationId": "v1ResumeCampaign", - "tags": [ - "Campaigns" - ], - "summary": "Resume a paused campaign", - "description": "Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body.\n\nRequires the `campaigns:write` scope — Create, edit, and organize your campaigns.", + }, "security": [ { "ApiKeyAuth": [] @@ -9176,115 +9347,103 @@ }, { "OAuth2": [ - "campaigns:write" + "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": { - "type": "string", "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "type": "string" + } } ], "responses": { "200": { - "description": "The resumed campaign", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1" + "$ref": "#/components/schemas/SuccessEmpty" } } - } + }, + "description": "Domain removed" }, "400": { - "description": "`validation_error` — only a `PAUSED` campaign can be resumed.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/campaigns/{id}/stats": { - "get": { - "operationId": "v1GetCampaignStats", - "tags": [ - "Campaigns" - ], - "summary": "Retrieve campaign statistics", - "description": "Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight.\n\nRates are percentages (0–100) against `sent`, and are 0 before anything has been sent.\n\nRequires the `campaigns:read` scope — View your campaigns and their performance.", + }, "security": [ { "ApiKeyAuth": [] @@ -9294,105 +9453,116 @@ }, { "OAuth2": [ - "campaigns:read" + "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": { - "type": "string", "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "type": "string" + } } ], "responses": { "200": { - "description": "Campaign statistics", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1Stats" + "properties": { + "data": { + "$ref": "#/components/schemas/Domain" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Domain" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/campaigns/{id}/failures": { - "get": { - "operationId": "v1ListCampaignFailures", - "tags": [ - "Campaigns" - ], - "summary": "List a campaign's failed sends", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -9402,127 +9572,126 @@ }, { "OAuth2": [ - "campaigns:read" + "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": { - "type": "string", "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignDomainStream" + } + } }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Domain" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" + } + } }, - "required": false, - "name": "limit", - "in": "query" + "description": "Updated sending identity" }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Failed sends", + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1FailureList" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/campaigns/{id}/retry-failed": { - "post": { - "operationId": "v1RetryCampaignFailures", - "tags": [ - "Campaigns" - ], - "summary": "Retry a campaign's failed sends", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -9532,125 +9701,137 @@ }, { "OAuth2": [ - "campaigns:write" + "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": { - "type": "string", "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "type": "string" + } } ], "responses": { "200": { - "description": "The retry was queued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CampaignV1RetryFailed" + "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": { - "description": "`validation_error` — only a `SENT` campaign can have its failed sends retried.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "409": { - "description": "`conflict` — a retry is already running for this campaign.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/segments": { - "get": { - "operationId": "v1ListSegments", - "tags": [ - "Segments" - ], - "summary": "List segments", - "description": "Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\n`member_count` is materialized on each segment, so listing segments never fans out into one count per segment — it is refreshed as membership changes rather than computed on read.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + }, "security": [ { "ApiKeyAuth": [] @@ -9660,104 +9841,118 @@ }, { "OAuth2": [ - "segments:read" + "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": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Segment list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1List" + "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": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "post": { - "operationId": "v1CreateSegment", - "tags": [ - "Segments" - ], - "summary": "Create a segment", - "description": "Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later.\n\nA `DYNAMIC` segment requires a `condition`, which is validated and evaluated during the request: the response's `member_count` tells you immediately how many contacts the filter actually matches.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + }, "security": [ { "ApiKeyAuth": [] @@ -9767,102 +9962,116 @@ }, { "OAuth2": [ - "segments:write" + "domains:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SegmentV1Create" - } + "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": { - "201": { - "description": "Segment created", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1" + "properties": { + "data": { + "$ref": "#/components/schemas/DomainVerificationStatus" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Verification status" }, "400": { - "description": "`validation_error` — a `DYNAMIC` segment was submitted without a `condition`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/segments/{id}": { - "get": { - "operationId": "v1GetSegment", - "tags": [ - "Segments" - ], - "summary": "Retrieve a segment", - "description": "Fetch one segment, including its saved `condition` and materialized `member_count`.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + }, "security": [ { "ApiKeyAuth": [] @@ -9872,103 +10081,128 @@ }, { "OAuth2": [ - "segments:read" + "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": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "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": { - "description": "The segment", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1" - } - } - } - }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/EmailListResponse" } } - } + }, + "description": "Email list" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "404": { - "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "patch": { - "operationId": "v1UpdateSegment", - "tags": [ - "Segments" - ], - "summary": "Update a segment", - "description": "Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list.\n\n`type` is not accepted here — see the create operation.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + }, "security": [ { "ApiKeyAuth": [] @@ -9978,113 +10212,134 @@ }, { "OAuth2": [ - "segments:write" + "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": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "maxLength": 255, + "minLength": 1, + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1Update" + "$ref": "#/components/schemas/SendEmail" } } - } + }, + "required": true }, "responses": { "200": { - "description": "The updated segment", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1" + "$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": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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." }, - "404": { - "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "409": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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." } - } - }, - "delete": { - "operationId": "v1DeleteSegment", - "tags": [ - "Segments" - ], - "summary": "Delete a segment", - "description": "Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first.\n\nRequires the `segments:write` scope — Create, edit, and delete your segments.", + }, "security": [ { "ApiKeyAuth": [] @@ -10094,115 +10349,144 @@ }, { "OAuth2": [ - "segments:write" + "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": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "maxLength": 255, + "minLength": 1, + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchSendBody" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Segment deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentV1Deleted" + "$ref": "#/components/schemas/BatchSendResponse" } } - } + }, + "description": "All entries sent" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "207": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/BatchSendResponse" } } - } + }, + "description": "Partial success — at least one entry failed" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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." }, - "404": { - "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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": { - "description": "`conflict` — the segment is still used by one or more active campaigns.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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." } - } - } - }, - "/api/v1/segments/{id}/contacts": { - "get": { - "operationId": "v1ListSegmentContacts", - "tags": [ - "Segments" - ], - "summary": "List the contacts in a segment", - "description": "Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now.\n\nCursors from this endpoint are not interchangeable with cursors from other list endpoints — the ordering differs — and pairing one with the wrong endpoint answers 422 rather than silently paging a different set.\n\nRequires the `segments:read` scope — View your segments and who belongs to them.", + }, "security": [ { "ApiKeyAuth": [] @@ -10212,127 +10496,103 @@ }, { "OAuth2": [ - "segments:read" + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { + "required": true, "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Segment member list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SegmentContactV1List" + "$ref": "#/components/schemas/EmailDetailResponse" } } - } + }, + "description": "Email and its delivery history" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no segment with this id belongs to the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/workflows": { - "get": { - "operationId": "v1ListWorkflows", - "tags": [ - "Workflows" - ], - "summary": "List workflows", - "description": "Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page.\n\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.", + }, "security": [ { "ApiKeyAuth": [] @@ -10342,431 +10602,467 @@ }, { "OAuth2": [ - "workflows:read" + "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": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Workflow list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1List" + "$ref": "#/components/schemas/EmailResponse" } } - } + }, + "description": "Email cancelled" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Email already past PENDING" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "post": { - "operationId": "v1CreateWorkflow", - "tags": [ - "Workflows" - ], - "summary": "Create a workflow", - "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.", + }, "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": [ { - "OAuth2": [ - "workflows:write" - ] + "description": "List id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "List id.", + "minLength": 1, + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowCreateV1" + "$ref": "#/components/schemas/ListSubscribe" } } - } + }, + "required": true }, "responses": { - "201": { - "description": "Workflow created", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1" + "$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": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/workflows/{id}": { - "get": { - "operationId": "v1GetWorkflow", - "tags": [ - "Workflows" - ], - "summary": "Retrieve a workflow", - "description": "The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.", + }, "security": [ { "ApiKeyAuth": [] }, { "SessionAuth": [] - }, - { - "OAuth2": [ - "workflows:read" - ] } ], + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", + "description": "List id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "List id.", + "minLength": 1, + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUnsubscribe" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Workflow", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1" + "$ref": "#/components/schemas/ListUnsubscribeResponse" + } + } + }, + "description": "Contact unsubscribed" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "patch": { - "operationId": "v1UpdateWorkflow", - "tags": [ - "Workflows" - ], - "summary": "Update a workflow", - "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.", + }, "security": [ { "ApiKeyAuth": [] }, { "SessionAuth": [] - }, - { - "OAuth2": [ - "workflows:write" - ] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", - "name": "id", - "in": "path" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkflowUpdateV1" - } - } - } - }, + "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": { - "description": "Updated workflow", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1" - } - } - } - }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Mailbox" + }, + "type": "array" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Mailbox list" }, - "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "409": { - "description": "`conflict` — the trigger cannot be changed while executions are running.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "delete": { - "operationId": "v1DeleteWorkflow", - "tags": [ - "Workflows" - ], - "summary": "Delete a workflow", - "description": "Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + }, "security": [ { "ApiKeyAuth": [] @@ -10776,389 +11072,380 @@ }, { "OAuth2": [ - "workflows:write" + "mailboxes:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", - "name": "id", - "in": "path" - } - ], + "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": { - "200": { - "description": "Workflow deleted", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowDeletedV1" + "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": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "409": { - "description": "`conflict` — the workflow still has running executions.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "The address already exists, the domain is not verified, or the project is at its 10-mailbox limit." }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "429": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, - "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "500": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" }, - "500": { - "description": "`internal_error`.", + "502": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried." } - } - } - }, - "/api/v1/workflows/{id}/executions": { - "get": { - "operationId": "v1ListWorkflowExecutions", - "tags": [ - "Workflows" - ], - "summary": "List a workflow's executions", - "description": "One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs.\n\nRequires the `workflows:read` scope — View your automation workflows and their runs.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:read" + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - }, - { + "required": true, "schema": { - "type": "string", - "enum": [ - "RUNNING", - "WAITING", - "COMPLETED", - "EXITED", - "FAILED", - "CANCELLED" - ], - "description": "Return only executions in this state." - }, - "required": false, - "description": "Return only executions in this state.", - "name": "status", - "in": "query" + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Execution list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowExecutionV1List" + "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" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "post": { - "operationId": "v1StartWorkflowExecution", - "tags": [ - "Workflows" - ], - "summary": "Start a workflow for a contact", - "description": "Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished.\n\n409 when the workflow's re-entry policy already accounts for this contact; 429 when the workflow's own `max_executions_per_hour` cap is reached.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:write" + "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": { - "type": "string", "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkflowExecutionStartV1" - } + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "Execution started", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowExecutionV1" - } - } - } - }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + "properties": { + "data": { + "$ref": "#/components/schemas/MailboxDetail" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Mailbox with connection settings" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "404": { - "description": "`resource_not_found` — no such workflow, or no such contact in this project.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "409": { - "description": "`conflict` — the contact already has an execution and re-entry is not allowed.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/workflows/executions/{execution_id}/cancel": { - "post": { - "operationId": "v1CancelWorkflowExecution", - "tags": [ - "Workflows" - ], - "summary": "Cancel a workflow execution", - "description": "Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it.\n\nRequires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows.", + }, "security": [ { "ApiKeyAuth": [] @@ -11168,105 +11455,121 @@ }, { "OAuth2": [ - "workflows:write" + "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": { - "type": "string", "format": "uuid", - "description": "Workflow execution id." - }, - "required": true, - "description": "Workflow execution id.", - "name": "execution_id", - "in": "path" + "type": "string" + } } ], "responses": { "200": { - "description": "Cancelled execution", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowExecutionV1" + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AppPassword" + }, + "type": "array" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "App password list" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no execution with this id in the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/workflows/{id}/stats": { - "get": { - "operationId": "v1GetWorkflowStats", - "tags": [ - "Workflows" - ], - "summary": "Retrieve workflow statistics", - "description": "Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow.\n\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.", + }, "security": [ { "ApiKeyAuth": [] @@ -11276,353 +11579,438 @@ }, { "OAuth2": [ - "workflows:read" + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" - }, - { + "required": true, "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time." - }, - "required": false, - "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time.", - "name": "from", - "in": "query" + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppPassword" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "Workflow statistics", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowStatsV1" + "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" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/workflows/{id}/graph": { - "get": { - "operationId": "v1GetWorkflowGraph", - "tags": [ - "Workflows" - ], - "summary": "Retrieve a workflow's step graph", - "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.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:read" + "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": { - "type": "string", "format": "uuid", - "description": "Workflow id." - }, + "type": "string" + } + }, + { + "in": "path", + "name": "passwordId", "required": true, - "description": "Workflow id.", - "name": "id", - "in": "path" + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "The workflow's graph", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowGraphV1" + "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" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "put": { - "operationId": "v1ReplaceWorkflowGraph", - "tags": [ - "Workflows" - ], - "summary": "Replace a workflow's step graph", - "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.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:write" + "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": { - "type": "string", "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", - "name": "id", - "in": "path" + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowGraphReplaceV1" + "$ref": "#/components/schemas/DraftMailboxMessage" } } - } + }, + "required": true }, "responses": { "200": { - "description": "The graph as it now stands", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowGraphV1" + "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." }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "409": { - "description": "`conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "429": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, - "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", + "500": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" }, - "500": { - "description": "`internal_error`.", + "502": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "The drafting model was unreachable or returned nothing usable." } - } - } - }, - "/api/v1/workflows/{id}/clone": { - "post": { - "operationId": "v1CloneWorkflow", - "tags": [ - "Workflows" - ], - "summary": "Clone a workflow", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -11632,115 +12020,181 @@ }, { "OAuth2": [ - "workflows:write" + "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": { - "type": "string", "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", - "name": "id", - "in": "path" + "type": "string" + } } ], "requestBody": { - "required": false, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowCloneV1" + "$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" + } } - } - } - }, - "responses": { - "201": { - "description": "The cloned workflow", + }, + "description": "Message submitted" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowV1" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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." } - } - } - }, - "/api/v1/workflows/{id}/pause": { - "post": { - "operationId": "v1PauseWorkflow", - "tags": [ - "Workflows" - ], - "summary": "Pause a workflow and cancel its running executions", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -11750,604 +12204,630 @@ }, { "OAuth2": [ - "workflows:write" + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Workflow id." - }, - "required": true, - "description": "Workflow id.", + "description": "Project id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Project id.", + "type": "string" + } } ], "responses": { "200": { - "description": "The workflow, and the number of runs this call cancelled", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowStateChangeV1" + "$ref": "#/components/schemas/ApiKeyListResponse" } } - } + }, + "description": "API key list" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/workflows/{id}/resume": { - "post": { - "operationId": "v1ResumeWorkflow", - "tags": [ - "Workflows" - ], - "summary": "Resume a paused workflow", - "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.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "workflows:write" + "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": { - "type": "string", - "format": "uuid", - "description": "Workflow id." + "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" + } + } }, - "required": true, - "description": "Workflow id.", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "The workflow, with `cancelled_executions` always 0", + "description": "API key created; the secret is behind the reveal link." + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowStateChangeV1" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no workflow with this id in the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/emails": { - "post": { - "operationId": "v1SendEmail", - "tags": [ - "Emails" - ], - "summary": "Send a transactional email", - "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.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "emails:send" + "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": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." - }, - "required": false, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", - "name": "Idempotency-Key", - "in": "header" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendEmailV1" - } + "description": "Project id.", + "type": "string" + } + }, + { + "description": "API key id.", + "in": "path", + "name": "keyId", + "required": true, + "schema": { + "description": "API key id.", + "type": "string" } } - }, + ], "responses": { - "202": { - "description": "Email queued", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailV1" - } - } - } - }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/SuccessEmpty" } } - } + }, + "description": "API key revoked" }, - "403": { - "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.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "404": { - "description": "`resource_not_found` — `template` names a template that does not belong to this project.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "409": { - "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "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.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "503": { - "description": "`content_review_unavailable` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/emails/test": { - "post": { - "operationId": "v1SendTestEmail", - "tags": [ - "Emails" - ], - "summary": "Send a sandbox test email", - "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.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "emails:test" + "api-keys:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendTestEmailV1" + "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" + } } - } - } - }, - "responses": { - "202": { - "description": "Test email queued", + }, + "description": "API key rotated; the new secret is behind the reveal link." + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTestV1" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "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.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "409": { - "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.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "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.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "503": { - "description": "`content_review_unavailable` — content review could not run for this new account. Safe to retry.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/emails": { - "post": { - "operationId": "sendEmail", - "tags": [ - "Emails" - ], - "summary": "Send a single transactional email", - "description": "Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result.\n\nRequires the `emails:send` scope — Send emails from your verified domains.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "emails:send" + "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": { - "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request." - }, + "type": "string" + } + }, + { + "in": "query", + "name": "search", "required": false, - "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", - "name": "Idempotency-Key", - "in": "header" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendEmail" - } + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Email accepted / sent", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SendEmailResponse" + "$ref": "#/components/schemas/SnippetListResponse" } } - } + }, + "description": "Snippet list" }, "400": { - "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "422": { - "description": "`IDEMPOTENCY_KEY_REUSED` — this `Idempotency-Key` was already used for a request with a different body. Reuse a key only to retry the identical request; otherwise send a new key.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "`CONTENT_REVIEW_UNAVAILABLE` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "get": { - "operationId": "listEmails", - "tags": [ - "Emails" - ], - "summary": "List emails", - "description": "List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets.\n\nRequires the `emails:read` scope — View the emails you have sent and their delivery status.", + }, "security": [ { "ApiKeyAuth": [] @@ -12357,128 +12837,125 @@ }, { "OAuth2": [ - "emails:read" + "templates:read" ] } ], - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" + "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" + } + } }, - { - "schema": { - "type": "string" + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Snippet" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" + } + } }, - "required": false, - "name": "tag", - "in": "query" + "description": "Snippet created" }, - { - "schema": { - "$ref": "#/components/schemas/EmailDeliveryStatus" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "required": false, - "description": "Delivery lifecycle of the message. Engagement is reported separately.", - "name": "status", - "in": "query" + "description": "Validation error" }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "from", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Email list", + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailListResponse" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "400": { - "description": "Validation error", + "403": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "409": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "A snippet with that name already exists in this project" }, - "403": { - "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": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/emails/{id}": { - "get": { - "operationId": "getEmail", - "tags": [ - "Emails" - ], - "summary": "Get a single email", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -12488,103 +12965,103 @@ }, { "OAuth2": [ - "emails:read" + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Email and its delivery history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailDetailResponse" + "$ref": "#/components/schemas/IdResponse" } } - } + }, + "description": "Snippet deleted" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/emails/batch": { - "post": { - "operationId": "sendEmailBatch", - "tags": [ - "Emails" - ], - "summary": "Send a batch of emails", - "description": "Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array.\n\nThe whole batch is ONE idempotent unit: an `Idempotency-Key` replayed with the same entry list replays the same per-index results, and replaying it with an edited list answers `422 IDEMPOTENCY_KEY_REUSED` rather than returning results for indexes the new body no longer has.\n\nRequires the `emails:send` scope — Send emails from your verified domains.", + }, "security": [ { "ApiKeyAuth": [] @@ -12594,255 +13071,265 @@ }, { "OAuth2": [ - "emails:send" + "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": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "required": false, - "name": "Idempotency-Key", - "in": "header" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchSendBody" - } + "format": "uuid", + "type": "string" } } - }, + ], "responses": { "200": { - "description": "All entries sent", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchSendResponse" - } - } - } - }, - "207": { - "description": "Partial success — at least one entry failed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchSendResponse" + "properties": { + "data": { + "$ref": "#/components/schemas/Snippet" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Snippet" }, "400": { - "description": "Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "`CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`IDEMPOTENCY_KEY_REUSED` — this `Idempotency-Key` was already used for a request with a different body. Reuse a key only to retry the identical request; otherwise send a new key.", + "404": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "`CONTENT_REVIEW_UNAVAILABLE` — content review could not run for this new account, so the message was not accepted. Safe to retry after a short delay.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/emails/{id}/schedule": { - "delete": { - "operationId": "cancelScheduledEmail", - "tags": [ - "Emails" - ], - "summary": "Cancel a scheduled (still-PENDING) email", - "description": "Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING.", + }, "security": [ { "ApiKeyAuth": [] }, { "SessionAuth": [] + }, + { + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSnippet" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Email cancelled", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailResponse" + "properties": { + "data": { + "$ref": "#/components/schemas/Snippet" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Updated snippet" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "409": { - "description": "Email already past PENDING", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "A snippet with that name already exists in this project" }, - "429": { - "description": "Rate limit or billing limit exceeded", + "422": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, - "500": { - "description": "Internal server error", + "429": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } - } - } - } - }, - "/api/contacts": { - "get": { - "operationId": "listContacts", - "tags": [ - "Contacts" - ], - "summary": "List contacts", - "description": "Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.", + }, + "description": "Rate limit or billing limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal server error" + } + }, "security": [ { "ApiKeyAuth": [] @@ -12852,131 +13339,117 @@ }, { "OAuth2": [ - "contacts:read" + "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": { - "type": "integer", - "minimum": 1, + "default": 50, "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" + "minimum": 1, + "type": "integer" + } }, { - "schema": { - "type": "string" - }, - "required": false, + "in": "query", "name": "cursor", - "in": "query" - }, - { + "required": false, "schema": { "type": "string" - }, - "required": false, - "name": "search", - "in": "query" + } }, { + "in": "query", + "name": "reason", + "required": false, "schema": { - "type": "string", "enum": [ - "true", - "false" - ] - }, - "required": false, - "name": "subscribed", - "in": "query" + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "type": "string" + } } ], "responses": { "200": { - "description": "Contact list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactListResponse" + "$ref": "#/components/schemas/SuppressionListResponse" } } - } + }, + "description": "Suppression list" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "post": { - "operationId": "createContact", - "tags": [ - "Contacts" - ], - "summary": "Create a contact", - "description": "Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.", + }, "security": [ { "ApiKeyAuth": [] @@ -12986,127 +13459,90 @@ }, { "OAuth2": [ - "contacts:write" + "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": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateContact" + "$ref": "#/components/schemas/AddSuppression" } } - } + }, + "required": true }, "responses": { "201": { - "description": "Contact created", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Contact" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/Suppression" } } - } + }, + "description": "Suppression added" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Email already exists for this project", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } - }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/contacts/upsert": { - "post": { - "operationId": "upsertContact", - "tags": [ - "Contacts" - ], - "summary": "Create or update a contact by email", - "description": "Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.", + }, "security": [ { "ApiKeyAuth": [] @@ -13116,117 +13552,87 @@ }, { "OAuth2": [ - "contacts:write" + "suppression:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateContact" - } + "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": { - "200": { - "description": "Contact created or updated", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Contact" - } - }, - "required": [ - "success", - "data" - ] - } - } - } + "204": { + "description": "Suppression removed" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } - }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/contacts/bulk": { - "post": { - "operationId": "bulkCreateContacts", - "tags": [ - "Contacts" - ], - "summary": "Bulk-create contacts", - "description": "Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`.\n\nRequires the `contacts:write` scope — Create, update, and delete your contacts.", + }, "security": [ { "ApiKeyAuth": [] @@ -13236,146 +13642,92 @@ }, { "OAuth2": [ - "contacts:write" + "suppression:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactBulkCreateBody" - } + "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": { - "description": "Bulk-create result", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "created": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "index": { - "type": "integer" - }, - "message": { - "type": "string" - } - }, - "required": [ - "index", - "message" - ] - } - } - }, - "required": [ - "created", - "skipped", - "errors" - ] - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/SuppressionCheckResponse" } } - } + }, + "description": "Suppression check result" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "delete": { - "operationId": "bulkDeleteContacts", - "tags": [ - "Contacts" - ], - "summary": "Bulk-delete contacts", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -13385,125 +13737,135 @@ }, { "OAuth2": [ - "contacts:write" + "suppression:read" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactBulkDeleteBody" - } + "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": { - "description": "Bulk-delete result", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "deleted": { - "type": "integer" - } - }, - "required": [ - "deleted" - ] - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/TemplateListResponse" } } - } + }, + "description": "Template list" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/contacts/{id}": { - "get": { - "operationId": "getContact", - "tags": [ - "Contacts" - ], - "summary": "Get a contact", - "description": "Requires the `contacts:read` scope — View your contacts and their custom fields.", + }, "security": [ { "ApiKeyAuth": [] @@ -13513,116 +13875,115 @@ }, { "OAuth2": [ - "contacts:read" + "templates:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], + "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": { - "200": { - "description": "Contact", + "201": { "content": { "application/json": { "schema": { - "type": "object", "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, "success": { - "type": "boolean", "enum": [ true - ] - }, - "data": { - "$ref": "#/components/schemas/Contact" + ], + "type": "boolean" } }, "required": [ "success", "data" - ] + ], + "type": "object" } } - } + }, + "description": "Template created" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "404": { - "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": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "patch": { - "operationId": "updateContact", - "tags": [ - "Contacts" - ], - "summary": "Update a contact", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -13632,136 +13993,113 @@ }, { "OAuth2": [ - "contacts:write" + "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": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + { + "in": "path", "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateContactBody" - } + "required": true, + "schema": { + "format": "uuid", + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Updated contact", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Contact" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/IdResponse" } } - } + }, + "description": "Template deleted" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "409": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Template still in use" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "delete": { - "operationId": "deleteContact", - "tags": [ - "Contacts" - ], - "summary": "Delete a contact", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -13771,103 +14109,116 @@ }, { "OAuth2": [ - "contacts:write" + "templates:write" ] } ], + "summary": "Delete a template", + "tags": [ + "Templates" + ] + }, + "get": { + "description": "Requires the `templates:read` scope — View your email templates.", + "operationId": "getTemplate", "parameters": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Contact deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdResponse" + "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, + "success": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "success", + "data" + ], + "type": "object" } } - } + }, + "description": "Template" }, "400": { - "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "404": { - "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/contacts": { - "get": { - "operationId": "v1ListContacts", - "tags": [ - "Contacts" - ], - "summary": "List contacts", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -13877,129 +14228,136 @@ }, { "OAuth2": [ - "contacts:read" + "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": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Case-insensitive substring match on the email address." - }, - "required": false, - "description": "Case-insensitive substring match on the email address.", - "name": "search", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "true", - "false" - ], - "description": "Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both." - }, - "required": false, - "description": "Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both.", - "name": "subscribed", - "in": "query" + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTemplate" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Contact list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactV1List" + "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": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$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": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "post": { - "operationId": "v1CreateContact", - "tags": [ - "Contacts" - ], - "summary": "Create a contact", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -14009,324 +14367,325 @@ }, { "OAuth2": [ - "contacts:write" + "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": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactV1Create" + "$ref": "#/components/schemas/TrackEvent" } } - } + }, + "required": true }, "responses": { - "201": { - "description": "The created contact", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactV1" - } - } - } - }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/TrackEventResponse" } } - } + }, + "description": "Event tracked" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "409": { - "description": "`conflict` — a contact with this email already exists in this project.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/contacts/{id}": { - "get": { - "operationId": "v1GetContact", - "tags": [ - "Contacts" - ], - "summary": "Retrieve a contact", - "description": "Fetch one contact by id.\n\nRequires the `contacts:read` scope — View your contacts and their custom fields.", + }, "security": [ { "ApiKeyAuth": [] }, - { - "SessionAuth": [] - }, { "OAuth2": [ - "contacts:read" + "events:write" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" - } - ], + "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": { - "200": { - "description": "The contact", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactV1" + "$ref": "#/components/schemas/ProjectRecord" } } - } + }, + "description": "Project created" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "404": { - "description": "`resource_not_found` — no contact with this id belongs to the authenticated project.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation failed — request body or query parameters did not match the schema" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - }, - "patch": { - "operationId": "v1UpdateContact", - "tags": [ - "Contacts" - ], - "summary": "Update a contact", - "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.", + }, "security": [ - { - "ApiKeyAuth": [] - }, { "SessionAuth": [] }, { "OAuth2": [ - "contacts:write" + "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": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactV1Update" - } + "description": "End of the window (ISO 8601). Defaults to now.", + "format": "date-time", + "type": [ + "string", + "null" + ] } } - }, + ], "responses": { "200": { - "description": "The updated contact", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactV1" + "$ref": "#/components/schemas/AnalyticsCampaignStatsV1" } } - } + }, + "description": "Campaign statistics" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no contact with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "v1DeleteContact", - "tags": [ - "Contacts" - ], - "summary": "Delete a contact", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -14336,361 +14695,347 @@ }, { "OAuth2": [ - "contacts:write" + "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": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "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": { - "description": "Contact deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactV1Deleted" + "$ref": "#/components/schemas/AnalyticsTimeseriesV1" } } - } + }, + "description": "Daily time series" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no contact with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/lists/{id}/subscribe": { - "post": { - "operationId": "subscribeToList", - "tags": [ - "Lists" - ], - "summary": "Subscribe a contact to a list", - "description": "Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-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.", + }, "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": { - "type": "string", - "minLength": 1, - "description": "List id." - }, - "required": true, - "description": "List id.", - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListSubscribe" - } + "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": { - "description": "Contact subscribed, or an existing membership returned unchanged", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListSubscribeResponse" - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/AnalyticsTopCampaignsV1" } } - } + }, + "description": "Ranked campaigns" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "The contact previously unsubscribed from this list and `allowResubscribe` was not set. `error.code` is `RESUBSCRIBE_CONFIRMATION_REQUIRED` and `error.details.previousStatus` is `UNSUBSCRIBED`. Retry with `allowResubscribe: true` once the contact has consented.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/lists/{id}/unsubscribe": { - "post": { - "operationId": "unsubscribeFromList", - "tags": [ - "Lists" - ], - "summary": "Unsubscribe a contact from a list", - "description": "Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds.\n\nOnce a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it.", + }, "security": [ { "ApiKeyAuth": [] }, { "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": { - "type": "string", - "minLength": 1, - "description": "List id." - }, - "required": true, - "description": "List id.", - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListUnsubscribe" - } - } - } - }, - "responses": { - "200": { - "description": "Contact unsubscribed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListUnsubscribeResponse" - } - } + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer" } }, - "400": { - "description": "Validation error", + { + "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/Error" + "$ref": "#/components/schemas/CampaignV1List" } } - } + }, + "description": "Campaign list" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/lists": { - "get": { - "operationId": "v1ListLists", - "tags": [ - "Lists" - ], - "summary": "List subscriber lists", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -14700,104 +15045,124 @@ }, { "OAuth2": [ - "lists:read" + "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": [ { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "in": "header", + "name": "Idempotency-Key", "required": false, - "name": "limit", - "in": "query" - }, - { "schema": { - "type": "string", + "description": "Replay-safety key (24h TTL). Reuse it only to retry the identical request.", + "maxLength": 255, "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1Create" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "Subscriber lists", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListV1List" + "$ref": "#/components/schemas/CampaignV1" } } - } + }, + "description": "Campaign created" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "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": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "v1CreateList", - "tags": [ - "Lists" - ], - "summary": "Create a subscriber list", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -14807,92 +15172,115 @@ }, { "OAuth2": [ - "lists:write" + "campaigns:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListV1Create" - } + "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": { - "201": { - "description": "The created list", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListV1" + "$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": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/lists/{id}": { - "get": { - "operationId": "v1GetList", - "tags": [ - "Lists" - ], - "summary": "Retrieve a subscriber list", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -14902,103 +15290,103 @@ }, { "OAuth2": [ - "lists:read" + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "The list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListV1" + "$ref": "#/components/schemas/CampaignV1" } } - } + }, + "description": "The campaign" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no list with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "v1UpdateList", - "tags": [ - "Lists" - ], - "summary": "Update a subscriber list", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -15008,113 +15396,123 @@ }, { "OAuth2": [ - "lists:write" + "campaigns:read" ] } ], + "summary": "Retrieve a campaign", + "tags": [ + "Campaigns" + ] + }, + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListV1Update" + "$ref": "#/components/schemas/CampaignV1Update" } } - } + }, + "required": true }, "responses": { "200": { - "description": "The updated list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListV1" + "$ref": "#/components/schemas/CampaignV1" } } - } + }, + "description": "The updated campaign" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "`validation_error` — the campaign is not in an editable status, or the segment change is not allowed." }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no list with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "v1DeleteList", - "tags": [ - "Lists" - ], - "summary": "Delete a subscriber list", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -15124,105 +15522,115 @@ }, { "OAuth2": [ - "lists:write" + "campaigns:write" ] } ], + "summary": "Update a campaign", + "tags": [ + "Campaigns" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "List deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListV1Deleted" + "$ref": "#/components/schemas/CampaignV1" } } - } + }, + "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." }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no list with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/domains": { - "get": { - "operationId": "listDomains", - "tags": [ - "Domains" - ], - "summary": "List sending domains", - "description": "List all domains for the authenticated project.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.", + }, "security": [ { "ApiKeyAuth": [] @@ -15232,80 +15640,127 @@ }, { "OAuth2": [ - "domains:read" + "campaigns:write" ] } ], + "summary": "Cancel a campaign", + "tags": [ + "Campaigns" + ] + } + }, + "/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" + } + }, + { + "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": { - "description": "Domain list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainListResponse" + "$ref": "#/components/schemas/CampaignV1FailureList" } } - } + }, + "description": "Failed sends" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — 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": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "addDomain", - "tags": [ - "Domains" - ], - "summary": "Add a sending domain", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -15315,117 +15770,115 @@ }, { "OAuth2": [ - "domains:write" + "campaigns:read" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddDomainBody" - } + "summary": "List a campaign's failed sends", + "tags": [ + "Campaigns" + ] + } + }, + "/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" } } - }, + ], "responses": { - "201": { - "description": "Domain added", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Domain" - } - }, - "required": [ - "success", - "data" - ] + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignV1" } } - } + }, + "description": "The paused campaign" }, "400": { - "description": "Validation error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — only a `SENDING` campaign can be paused." }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "429": { - "description": "Rate limit or billing limit exceeded", + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no campaign with this id belongs to the authenticated project." }, - "500": { - "description": "Internal server error", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, - "502": { - "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.", + "429": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$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`." } - } - } - }, - "/api/domains/{id}": { - "get": { - "operationId": "getDomain", - "tags": [ - "Domains" - ], - "summary": "Get a sending domain", - "description": "Requires the `domains:read` scope — View your sending domains and their verification status.", + }, "security": [ { "ApiKeyAuth": [] @@ -15435,116 +15888,115 @@ }, { "OAuth2": [ - "domains:read" + "campaigns:write" ] } ], + "summary": "Pause a sending campaign", + "tags": [ + "Campaigns" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Domain", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Domain" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/CampaignV1" } } - } + }, + "description": "The resumed campaign" }, "400": { - "description": "Validation error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — only a `PAUSED` campaign can be resumed." }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$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": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "assignDomainStream", - "tags": [ - "Domains" - ], - "summary": "Assign a sending identity to a stream", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -15554,126 +16006,125 @@ }, { "OAuth2": [ - "domains:write" + "campaigns:write" ] } ], + "summary": "Resume a paused campaign", + "tags": [ + "Campaigns" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssignDomainStream" - } + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Updated sending identity", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Domain" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/CampaignV1RetryFailed" } } - } + }, + "description": "The retry was queued" }, "400": { - "description": "Validation error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — only a `SENT` campaign can have its failed sends retried." }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "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": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "deleteDomain", - "tags": [ - "Domains" - ], - "summary": "Remove a sending domain", - "description": "Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it.\n\nRequires the `domains:write` scope — Add and remove sending domains, and trigger verification.", + }, "security": [ { "ApiKeyAuth": [] @@ -15683,103 +16134,147 @@ }, { "OAuth2": [ - "domains:write" + "campaigns:write" ] } ], + "summary": "Retry a campaign's failed sends", + "tags": [ + "Campaigns" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "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/CampaignV1Send" + } + } + }, + "required": false + }, "responses": { "200": { - "description": "Domain removed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessEmpty" + "$ref": "#/components/schemas/CampaignV1" } } - } + }, + "description": "The campaign, now `SENDING` or `SCHEDULED`" }, "400": { - "description": "Validation error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future." }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "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." + }, + "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": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/domains/{id}/verify": { - "post": { - "operationId": "verifyDomain", - "tags": [ - "Domains" - ], - "summary": "Trigger SES verification", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -15789,116 +16284,105 @@ }, { "OAuth2": [ - "domains:write" + "campaigns:send" ] } ], + "summary": "Send or schedule a campaign", + "tags": [ + "Campaigns" + ] + } + }, + "/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", "parameters": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Verification status", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/DomainVerificationStatus" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/CampaignV1Stats" } } - } + }, + "description": "Campaign statistics" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no campaign with this id belongs to the authenticated project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "get": { - "operationId": "getDomainVerification", - "tags": [ - "Domains" - ], - "summary": "Read SES verification status", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -15907,119 +16391,132 @@ "SessionAuth": [] }, { - "OAuth2": [ - "domains:write" - ] - } - ], - "parameters": [ - { + "OAuth2": [ + "campaigns:read" + ] + } + ], + "summary": "Retrieve campaign statistics", + "tags": [ + "Campaigns" + ] + } + }, + "/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" + } + }, + { + "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": "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": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" + "description": "Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both.", + "enum": [ + "true", + "false" + ], + "type": "string" + } } ], "responses": { "200": { - "description": "Verification status", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/DomainVerificationStatus" - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ContactV1List" } } - } + }, + "description": "Contact list" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/domains/{id}/dodomain-session": { - "post": { - "operationId": "startDomainSetup", - "tags": [ - "Domains" - ], - "summary": "Start guided DNS setup", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -16029,137 +16526,100 @@ }, { "OAuth2": [ - "domains:write" + "contacts:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], + "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": { - "description": "Guided setup session", + "201": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "connectUrl": { - "type": "string", - "format": "uri", - "description": "Open this in a browser to publish the records. Short-lived and domain-specific." - }, - "expiresAt": { - "type": "string", - "description": "When `connectUrl` stops working." - } - }, - "required": [ - "token", - "connectUrl", - "expiresAt" - ] - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/ContactV1" } } - } + }, + "description": "The created contact" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`conflict` — a contact with this email already exists in this project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/domains": { - "get": { - "operationId": "v1ListDomains", - "tags": [ - "Domains" - ], - "summary": "List sending domains", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -16169,104 +16629,105 @@ }, { "OAuth2": [ - "domains:read" + "contacts:write" ] } ], + "summary": "Create a contact", + "tags": [ + "Contacts" + ] + } + }, + "/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": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Sending domain list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainV1List" + "$ref": "#/components/schemas/ContactV1Deleted" } } - } + }, + "description": "Contact deleted" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 contact with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "v1CreateDomain", - "tags": [ - "Domains" - ], - "summary": "Add a sending domain", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -16276,112 +16737,103 @@ }, { "OAuth2": [ - "domains:write" + "contacts:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DomainV1Create" - } + "summary": "Delete a contact", + "tags": [ + "Contacts" + ] + }, + "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" } } - }, + ], "responses": { - "201": { - "description": "The registered sending domain, awaiting DNS", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainV1" + "$ref": "#/components/schemas/ContactV1" } } - } + }, + "description": "The contact" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "409": { - "description": "`conflict` — this domain is already registered to a project you can send from.", + "404": { "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no contact with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "502": { - "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.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/domains/{id}": { - "get": { - "operationId": "v1GetDomain", - "tags": [ - "Domains" - ], - "summary": "Retrieve a sending domain", - "description": "Fetch one sending domain by id.\n\nRequires the `domains:read` scope — View your sending domains and their verification status.", + }, "security": [ { "ApiKeyAuth": [] @@ -16391,103 +16843,113 @@ }, { "OAuth2": [ - "domains:read" + "contacts:read" ] } ], + "summary": "Retrieve a contact", + "tags": [ + "Contacts" + ] + }, + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactV1Update" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "The sending domain", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainV1" + "$ref": "#/components/schemas/ContactV1" } } - } + }, + "description": "The updated contact" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no contact with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "v1DeleteDomain", - "tags": [ - "Domains" - ], - "summary": "Remove a sending domain", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -16497,115 +16959,94 @@ }, { "OAuth2": [ - "domains:write" + "contacts:write" ] } ], + "summary": "Update a contact", + "tags": [ + "Contacts" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", + "description": "The contact.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "The contact.", + "type": "string" + } } ], "responses": { "200": { - "description": "Sending domain removed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainV1Deleted" + "$ref": "#/components/schemas/ContactTopicPreferencesV1" } } - } + }, + "description": "The contact's preferences" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } - }, - "404": { - "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "409": { - "description": "`conflict` — the domain is still in use by a template, workflow step or active campaign.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/domains/{id}/verify": { - "post": { - "operationId": "v1VerifyDomain", - "tags": [ - "Domains" - ], - "summary": "Refresh a sending domain's verification state", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -16615,105 +17056,119 @@ }, { "OAuth2": [ - "domains:write" + "topics:read" ] } ], + "summary": "Get a contact's topic preferences", + "tags": [ + "Topics" + ] + } + }, + "/api/v1/deliverability/diagnose": { + "get": { + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, + "description": "A sending domain in this project, e.g. `example.com`.", + "in": "query", + "name": "domain", "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "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": "address", + "required": false, + "schema": { + "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": "window_days", + "required": false, + "schema": { + "description": "How far back the delivery counters look. 1–30 days; defaults to 7.", + "maximum": 30, + "minimum": 1, + "type": "integer" + } } ], "responses": { "200": { - "description": "The sending domain, as SES and DNS now report it", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainV1" + "$ref": "#/components/schemas/DeliverabilityDiagnosisV1" } } - } + }, + "description": "The diagnosis, with findings" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/templates": { - "get": { - "operationId": "listTemplates", - "tags": [ - "Templates" - ], - "summary": "List templates", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -16723,133 +17178,130 @@ }, { "OAuth2": [ - "templates:read" + "deliverability:read" ] } ], + "summary": "Diagnose why mail from a domain is not arriving", + "tags": [ + "Deliverability" + ] + } + }, + "/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": [ { + "in": "query", + "name": "limit", + "required": false, "schema": { - "type": "integer", - "minimum": 1, + "default": 20, "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" + "minimum": 1, + "type": "integer" + } }, { - "schema": { - "type": "string", - "minLength": 1 - }, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "in": "query", + "name": "after", "required": false, - "name": "cursor", - "in": "query" - }, - { "schema": { + "description": "Opaque cursor from a previous response's `next_cursor`.", + "minLength": 1, "type": "string" - }, - "required": false, - "name": "search", - "in": "query" + } }, { + "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": { - "type": "string", - "enum": [ - "MARKETING", - "TRANSACTIONAL", - "SELF_MANAGED_UNSUBSCRIBE" - ] - }, + "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, - "name": "emailCategory", - "in": "query" + "schema": { + "description": "Restrict to reports about one of your domains.", + "maxLength": 253, + "minLength": 1, + "type": "string" + } } ], "responses": { "200": { - "description": "Template list", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TemplateListResponse" - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/DmarcReportV1List" } } - } + }, + "description": "Cursor-paginated DMARC aggregate reports" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "createTemplate", - "tags": [ - "Templates" - ], - "summary": "Create a template", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -16859,236 +17311,130 @@ }, { "OAuth2": [ - "templates:write" + "deliverability:read" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTemplate" - } - } - } - }, - "responses": { - "201": { - "description": "Template created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Template" - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized — missing or invalid auth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Rate limit or billing limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } + "summary": "DMARC aggregate reports for your domains", + "tags": [ + "Deliverability" + ] } }, - "/api/templates/{id}": { + "/api/v1/deliverability/domains": { "get": { - "operationId": "getTemplate", - "tags": [ - "Templates" - ], - "summary": "Get a template", - "description": "Requires the `templates:read` scope — View your email templates.", - "security": [ + "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": [ { - "ApiKeyAuth": [] + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer" + } }, { - "SessionAuth": [] + "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. 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" + } }, { - "OAuth2": [ - "templates:read" - ] - } - ], - "parameters": [ - { + "description": "Restrict to one recipient domain.", + "in": "query", + "name": "domain", + "required": false, "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" + "description": "Restrict to one recipient domain.", + "maxLength": 253, + "minLength": 1, + "type": "string" + } } ], "responses": { "200": { - "description": "Template", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Template" - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/RecipientDomainStatsV1List" } } - } + }, + "description": "Cursor-paginated recipient-domain rollup" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "updateTemplate", - "tags": [ - "Templates" - ], - "summary": "Update a template", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -17098,136 +17444,106 @@ }, { "OAuth2": [ - "templates:write" + "deliverability:read" ] } ], + "summary": "Delivery outcomes per recipient domain", + "tags": [ + "Deliverability" + ] + } + }, + "/api/v1/domains": { + "get": { + "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": "query", + "name": "limit", + "required": false, "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateTemplate" - } + "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": { - "description": "Updated template", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Template" - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/DomainV1List" } } - } + }, + "description": "Sending domain list" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "deleteTemplate", - "tags": [ - "Templates" - ], - "summary": "Delete a template", - "description": "Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign.\n\nRequires the `templates:write` scope — Create, edit, and delete your email templates.", + }, "security": [ { "ApiKeyAuth": [] @@ -17237,113 +17553,110 @@ }, { "OAuth2": [ - "templates:write" + "domains:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], + "summary": "List sending domains", + "tags": [ + "Domains" + ] + }, + "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": { - "description": "Template deleted", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdResponse" + "$ref": "#/components/schemas/DomainV1" } } - } + }, + "description": "The registered sending domain, awaiting DNS" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`conflict` — this domain is already registered to a project you can send from." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, - "409": { - "description": "Template still in use", + "429": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, - "429": { - "description": "Rate limit or billing limit exceeded", + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." }, - "500": { - "description": "Internal server error", + "502": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "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." } - } - } - }, - "/api/v1/templates": { - "get": { - "operationId": "v1ListTemplates", - "tags": [ - "Templates" - ], - "summary": "List templates", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -17353,128 +17666,115 @@ }, { "OAuth2": [ - "templates:read" + "domains:write" ] } ], + "summary": "Add a sending domain", + "tags": [ + "Domains" + ] + } + }, + "/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": "Resource id.", + "in": "path", + "name": "id", + "required": true, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Case-insensitive substring match on the name." - }, - "required": false, - "description": "Case-insensitive substring match on the name.", - "name": "search", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "TRANSACTIONAL", - "MARKETING", - "SELF_MANAGED_UNSUBSCRIBE" - ] - }, - "required": false, - "name": "email_category", - "in": "query" + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Template list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateV1List" + "$ref": "#/components/schemas/DomainV1Deleted" } } - } + }, + "description": "Sending domain removed" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 sending domain with this id belongs to the authenticated project." + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "`conflict` — the domain is still in use by a template, workflow step or active campaign." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "v1CreateTemplate", - "tags": [ - "Templates" - ], - "summary": "Create a template", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -17484,92 +17784,103 @@ }, { "OAuth2": [ - "templates:write" + "domains:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TemplateV1Create" - } + "summary": "Remove a sending domain", + "tags": [ + "Domains" + ] + }, + "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": "Resource id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "The created template", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateV1" + "$ref": "#/components/schemas/DomainV1" } } - } + }, + "description": "The sending domain" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 sending domain with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/templates/{id}": { - "get": { - "operationId": "v1GetTemplate", - "tags": [ - "Templates" - ], - "summary": "Retrieve a template", - "description": "Fetch one template by id.\n\nRequires the `templates:read` scope — View your email templates.", + }, "security": [ { "ApiKeyAuth": [] @@ -17579,219 +17890,200 @@ }, { "OAuth2": [ - "templates:read" + "domains:read" ] } ], + "summary": "Retrieve a sending domain", + "tags": [ + "Domains" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "The template", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateV1" + "$ref": "#/components/schemas/DomainV1" } } - } + }, + "description": "The sending domain, as SES and DNS now report it" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no template with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no sending domain with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "v1UpdateTemplate", - "tags": [ - "Templates" - ], - "summary": "Update a template", - "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.", + }, "security": [ { "ApiKeyAuth": [] }, { - "SessionAuth": [] - }, - { - "OAuth2": [ - "templates:write" - ] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "SessionAuth": [] + }, + { + "OAuth2": [ + "domains:write" + ] } ], + "summary": "Refresh a sending domain's verification state", + "tags": [ + "Domains" + ] + } + }, + "/api/v1/email-validations": { + "post": { + "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": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateV1Update" + "$ref": "#/components/schemas/EmailValidationBatchRequestV1" } } - } + }, + "required": true }, "responses": { "200": { - "description": "The updated template", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateV1" + "$ref": "#/components/schemas/EmailValidationBatchV1" } } - } + }, + "description": "One verdict per address, in the order they were given" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no template with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "v1DeleteTemplate", - "tags": [ - "Templates" - ], - "summary": "Delete a template", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -17801,115 +18093,136 @@ }, { "OAuth2": [ - "templates:write" + "validation:write" ] } ], + "summary": "Validate a batch of email addresses", + "tags": [ + "Validation" + ] + } + }, + "/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": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "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 + }, "responses": { - "200": { - "description": "Template deleted", + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateV1Deleted" + "$ref": "#/components/schemas/EmailV1" } } - } + }, + "description": "Email queued" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "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": { - "description": "`resource_not_found` — no template with this id belongs to the authenticated project.", "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": { - "description": "`conflict` — the template is still referenced by a workflow step or an active campaign.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "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": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "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." } - } - } - }, - "/api/snippets": { - "get": { - "operationId": "listSnippets", - "tags": [ - "Templates" - ], - "summary": "List snippets", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -17919,250 +18232,233 @@ }, { "OAuth2": [ - "templates:read" + "emails:send" ] } ], - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": false, - "name": "cursor", - "in": "query" + "summary": "Send a transactional email", + "tags": [ + "Emails" + ] + } + }, + "/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" + } + } }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "search", - "in": "query" - } - ], + "required": true + }, "responses": { - "200": { - "description": "Snippet list", + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SnippetListResponse" + "$ref": "#/components/schemas/EmailTestV1" } } - } + }, + "description": "Test email queued" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "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": { - "description": "Forbidden — insufficient permissions or project disabled", + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "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." }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "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": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "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." } - } - }, - "post": { - "operationId": "createSnippet", - "tags": [ - "Templates" - ], - "summary": "Create a snippet", - "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.", + }, "security": [ { - "ApiKeyAuth": [] + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "emails:test" + ] + } + ], + "summary": "Send a sandbox test email", + "tags": [ + "Emails" + ] + } + }, + "/api/v1/events": { + "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", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer" + } }, { - "SessionAuth": [] + "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" + } }, { - "OAuth2": [ - "templates:write" - ] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSnippet" - } + "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": { - "201": { - "description": "Snippet created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Snippet" - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EventV1List" } } - } + }, + "description": "Event list" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "A snippet with that name already exists in this project", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/snippets/{id}": { - "get": { - "operationId": "getSnippet", - "tags": [ - "Templates" - ], - "summary": "Get a snippet", - "description": "Requires the `templates:read` scope — View your email templates.", + }, "security": [ { "ApiKeyAuth": [] @@ -18172,116 +18468,100 @@ }, { "OAuth2": [ - "templates:read" + "events:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Snippet", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Snippet" - } - }, - "required": [ - "success", - "data" - ] - } + "summary": "List events", + "tags": [ + "Events" + ] + }, + "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" } } }, - "400": { - "description": "Validation error", + "required": true + }, + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EventV1" } } - } + }, + "description": "Event recorded" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no contact with this id in 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": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "updateSnippet", - "tags": [ - "Templates" - ], - "summary": "Update a snippet", - "description": "Requires the `templates:write` scope — Create, edit, and delete your email templates.", + }, "security": [ { "ApiKeyAuth": [] @@ -18291,146 +18571,82 @@ }, { "OAuth2": [ - "templates:write" + "events:write" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSnippet" - } - } - } - }, - "responses": { - "200": { - "description": "Updated snippet", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Snippet" - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", + "summary": "Record an event", + "tags": [ + "Events" + ] + } + }, + "/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": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EventNamesV1" } } - } + }, + "description": "Event names" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "A snippet with that name already exists in this project", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "Validation failed — request body or query parameters did not match the schema", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "deleteSnippet", - "tags": [ - "Templates" - ], - "summary": "Delete a snippet", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -18440,103 +18656,112 @@ }, { "OAuth2": [ - "templates:write" + "events:read" ] } ], + "summary": "List event names", + "tags": [ + "Events" + ] + } + }, + "/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", "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": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" + "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": { - "description": "Snippet deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IdResponse" - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EventStatsV1" } } - } + }, + "description": "Event counts" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/webhooks": { - "get": { - "operationId": "listWebhooks", - "tags": [ - "Webhooks" - ], - "summary": "List user webhooks", - "description": "List all user-managed outbound webhooks for the auth'd project (secrets are not returned).\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", + }, "security": [ { "ApiKeyAuth": [] @@ -18546,80 +18771,106 @@ }, { "OAuth2": [ - "webhooks:read" + "events:read" ] } ], + "summary": "Retrieve event counts", + "tags": [ + "Events" + ] + } + }, + "/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": [ + { + "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": { - "description": "Webhook list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookListResponse" + "$ref": "#/components/schemas/ListV1List" } } - } + }, + "description": "Subscriber lists" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "createWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Create a webhook", - "description": "Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + }, "security": [ { "ApiKeyAuth": [] @@ -18629,92 +18880,90 @@ }, { "OAuth2": [ - "webhooks:write" + "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": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWebhook" + "$ref": "#/components/schemas/ListV1Create" } } - } + }, + "required": true }, "responses": { "201": { - "description": "Webhook created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookCreateResponse" + "$ref": "#/components/schemas/ListV1" } } - } + }, + "description": "The created list" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/webhooks/{id}": { - "get": { - "operationId": "getWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Get a webhook", - "description": "Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", + }, "security": [ { "ApiKeyAuth": [] @@ -18724,101 +18973,105 @@ }, { "OAuth2": [ - "webhooks:read" + "lists:write" ] } ], + "summary": "Create a subscriber list", + "tags": [ + "Lists" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Webhook", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookGetResponse" + "$ref": "#/components/schemas/ListV1Deleted" } } - } + }, + "description": "List deleted" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no list with this id belongs to the authenticated project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "updateWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Update a webhook", - "description": "Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + }, "security": [ { "ApiKeyAuth": [] @@ -18828,111 +19081,103 @@ }, { "OAuth2": [ - "webhooks:write" + "lists:write" ] } ], + "summary": "Delete a subscriber list", + "tags": [ + "Lists" + ] + }, + "get": { + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateWebhook" - } + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Webhook updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookGetResponse" + "$ref": "#/components/schemas/ListV1" } } - } + }, + "description": "The list" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no list with this id belongs to the authenticated project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "deleteWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Delete a webhook", - "description": "Hard-delete a webhook. Cascades to all WebhookCall rows.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + }, "security": [ { "ApiKeyAuth": [] @@ -18942,103 +19187,113 @@ }, { "OAuth2": [ - "webhooks:write" + "lists:read" ] } ], + "summary": "Retrieve a subscriber list", + "tags": [ + "Lists" + ] + }, + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListV1Update" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Webhook deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessEmpty" + "$ref": "#/components/schemas/ListV1" } } - } + }, + "description": "The updated list" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no list with this id belongs to the authenticated project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/webhooks/{id}/rotate-secret": { - "post": { - "operationId": "rotateWebhookSecret", - "tags": [ - "Webhooks" - ], - "summary": "Rotate the webhook signing secret", - "description": "Generate a new shared secret. Returns the new plaintext secret exactly once.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + }, "security": [ { "ApiKeyAuth": [] @@ -19048,103 +19303,94 @@ }, { "OAuth2": [ - "webhooks:write" + "lists:write" ] } ], + "summary": "Update a subscriber list", + "tags": [ + "Lists" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "The list to validate.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "The list to validate.", + "type": "string" + } } ], "responses": { - "200": { - "description": "Secret rotated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookRotateSecretResponse" - } - } - } - }, - "400": { - "description": "Validation error", + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/EmailValidationRunV1" } } - } + }, + "description": "The run, accepted and queued" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/webhooks/{id}/calls": { - "get": { - "operationId": "listWebhookCalls", - "tags": [ - "Webhooks" - ], - "summary": "List recent webhook calls", - "description": "Cursor-paginated list of recent delivery attempts for a single webhook.\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", + }, "security": [ { "ApiKeyAuth": [] @@ -19154,122 +19400,92 @@ }, { "OAuth2": [ - "webhooks:read" + "validation:write" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - } - ], + "summary": "Validate every address on a list", + "tags": [ + "Validation" + ] + } + }, + "/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", "responses": { "200": { - "description": "Webhook call history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookCallsListResponse" + "$ref": "#/components/schemas/ProjectV1" } } - } + }, + "description": "The authenticated project" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — the project was deleted between authentication and this read." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/webhooks": { - "get": { - "operationId": "v1ListWebhooks", - "tags": [ - "Webhooks" - ], - "summary": "List webhooks", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -19279,104 +19495,106 @@ }, { "OAuth2": [ - "webhooks:read" + "projects:read" ] } ], + "summary": "Retrieve the authenticated project", + "tags": [ + "Projects" + ] + } + }, + "/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", "parameters": [ { + "in": "query", + "name": "limit", + "required": false, "schema": { - "type": "integer", - "minimum": 1, + "default": 20, "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" + "minimum": 1, + "type": "integer" + } }, { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, "description": "Opaque cursor from a previous response's `next_cursor`.", + "in": "query", "name": "after", - "in": "query" + "required": false, + "schema": { + "description": "Opaque cursor from a previous response's `next_cursor`.", + "minLength": 1, + "type": "string" + } } ], "responses": { "200": { - "description": "Webhook list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookV1List" + "$ref": "#/components/schemas/SegmentV1List" } } - } + }, + "description": "Segment list" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "v1CreateWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Create a webhook", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -19386,92 +19604,100 @@ }, { "OAuth2": [ - "webhooks:write" + "segments:read" ] } ], + "summary": "List segments", + "tags": [ + "Segments" + ] + }, + "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": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookV1Create" + "$ref": "#/components/schemas/SegmentV1Create" } } - } + }, + "required": true }, "responses": { "201": { - "description": "The created webhook and its one-time signing secret", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookV1Created" + "$ref": "#/components/schemas/SegmentV1" } } - } + }, + "description": "Segment created" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "`validation_error` — a `DYNAMIC` segment was submitted without a `condition`." }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/webhooks/{id}": { - "get": { - "operationId": "v1GetWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Retrieve a webhook", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -19481,103 +19707,115 @@ }, { "OAuth2": [ - "webhooks:read" + "segments:write" ] } ], + "summary": "Create a segment", + "tags": [ + "Segments" + ] + } + }, + "/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", "parameters": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "The webhook", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookV1" + "$ref": "#/components/schemas/SegmentV1Deleted" } } - } + }, + "description": "Segment deleted" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "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": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "v1UpdateWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Update a webhook", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -19587,113 +19825,103 @@ }, { "OAuth2": [ - "webhooks:write" + "segments:write" ] } ], + "summary": "Delete a segment", + "tags": [ + "Segments" + ] + }, + "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": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookV1Update" - } + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" } } - }, + ], "responses": { "200": { - "description": "The updated webhook", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookV1" + "$ref": "#/components/schemas/SegmentV1" } } - } + }, + "description": "The segment" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "v1DeleteWebhook", - "tags": [ - "Webhooks" - ], - "summary": "Delete a webhook", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -19703,105 +19931,113 @@ }, { "OAuth2": [ - "webhooks:write" + "segments:read" ] } ], + "summary": "Retrieve a segment", + "tags": [ + "Segments" + ] + }, + "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", "parameters": [ { - "schema": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, "description": "Resource id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SegmentV1Update" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Webhook deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookV1Deleted" + "$ref": "#/components/schemas/SegmentV1" } } - } + }, + "description": "The updated segment" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/webhooks/{id}/rotate-secret": { - "post": { - "operationId": "v1RotateWebhookSecret", - "tags": [ - "Webhooks" - ], - "summary": "Rotate a webhook signing secret", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -19811,613 +20047,443 @@ }, { "OAuth2": [ - "webhooks:write" + "segments:write" ] } ], + "summary": "Update a segment", + "tags": [ + "Segments" + ] + } + }, + "/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" + } + }, + { + "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": { - "type": "string", - "format": "uuid", - "description": "Resource id." - }, - "required": true, - "description": "Resource id.", - "name": "id", - "in": "path" + "description": "Opaque cursor from a previous response's `next_cursor`.", + "minLength": 1, + "type": "string" + } } ], "responses": { "200": { - "description": "The new signing secret and the moment the previous one stops verifying", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookV1SecretRotated" + "$ref": "#/components/schemas/SegmentContactV1List" } } - } + }, + "description": "Segment member list" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "`resource_not_found` — no webhook with this id belongs to the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no segment with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/projects/{id}/api-keys": { - "post": { - "operationId": "createApiKey", - "tags": [ - "API Keys" - ], - "summary": "Create an API key", - "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.", + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "api-keys:write" + "segments:read" ] } ], + "summary": "List the contacts in a segment", + "tags": [ + "Segments" + ] + } + }, + "/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": { - "type": "string", - "description": "Project id." - }, - "required": true, - "description": "Project id.", - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateApiKeyBody" - } - } - } - }, - "responses": { - "201": { - "description": "API key created; the secret is behind the reveal link.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiKey" - }, - { - "type": "object", - "properties": { - "revealUrl": { - "type": "string", - "format": "uri", - "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." - }, - "revealExpiresAt": { - "type": "string", - "format": "date-time", - "description": "When the reveal link stops working. Create or rotate again to get a new one." - } - }, - "required": [ - "revealUrl", - "revealExpiresAt" - ] - } - ], - "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." - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized — missing or invalid auth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Rate limit or billing limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer" } }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - }, - "get": { - "operationId": "listApiKeys", - "tags": [ - "API Keys" - ], - "summary": "List API keys for a project", - "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.", - "security": [ { - "SessionAuth": [] + "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" + } }, { - "OAuth2": [ - "api-keys:read" - ] - } - ], - "parameters": [ - { + "description": "Filter to one reason. Omit for every suppressed address.", + "in": "query", + "name": "reason", + "required": false, "schema": { - "type": "string", - "description": "Project id." - }, - "required": true, - "description": "Project id.", - "name": "id", - "in": "path" + "description": "Filter to one reason. Omit for every suppressed address.", + "enum": [ + "HARD_BOUNCE", + "COMPLAINT", + "MANUAL", + "UNSUBSCRIBE" + ], + "type": "string" + } } ], "responses": { "200": { - "description": "API key list", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiKeyListResponse" - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/SuppressionV1List" } } - } + }, + "description": "Suppression list" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/api/projects/{id}/api-keys/{keyId}/rotate": { - "post": { - "operationId": "rotateApiKey", - "tags": [ - "API Keys" - ], - "summary": "Rotate an API key's secret", - "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.", + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "`internal_error`." + } + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "api-keys:write" + "suppression:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "Project id." - }, - "required": true, - "description": "Project id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "API key id." - }, - "required": true, - "description": "API key id.", - "name": "keyId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "API key rotated; the new secret is behind the reveal link.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "lastFour": { - "type": "string" - }, - "revealUrl": { - "type": "string", - "format": "uri", - "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." - }, - "revealExpiresAt": { - "type": "string", - "format": "date-time", - "description": "When the reveal link stops working. Create or rotate again to get a new one." - } - }, - "required": [ - "lastFour", - "revealUrl", - "revealExpiresAt" - ] - } - }, - "required": [ - "success", - "data" - ] - } + "summary": "List suppressed addresses", + "tags": [ + "Suppression" + ] + }, + "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" } } }, - "400": { - "description": "Validation error", + "required": true + }, + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/SuppressionV1" } } - } + }, + "description": "The suppressed address" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/projects/{id}/api-keys/{keyId}": { - "delete": { - "operationId": "revokeApiKey", - "tags": [ - "API Keys" - ], - "summary": "Revoke an API key", - "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.", + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "api-keys:write" + "suppression:write" ] } ], + "summary": "Suppress an address", + "tags": [ + "Suppression" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "description": "Project id." - }, + "description": "The suppressed address, URL-encoded.", + "in": "path", + "name": "email", "required": true, - "description": "Project id.", - "name": "id", - "in": "path" - }, - { "schema": { - "type": "string", - "description": "API key id." - }, - "required": true, - "description": "API key id.", - "name": "keyId", - "in": "path" + "description": "The suppressed address, URL-encoded.", + "format": "email", + "type": "string" + } } ], "responses": { "200": { - "description": "API key revoked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SuccessEmpty" - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/SuppressionV1Deleted" } } - } + }, + "description": "Address removed from the suppression list" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/suppression": { - "get": { - "operationId": "listSuppressions", - "tags": [ - "Suppression" - ], - "summary": "List suppressed emails", - "description": "Cursor-paginated list of suppressed addresses. Filter by `reason`.\n\nRequires the `suppression:read` scope — View the addresses on your suppression list.", + }, "security": [ { "ApiKeyAuth": [] @@ -20427,115 +20493,103 @@ }, { "OAuth2": [ - "suppression:read" + "suppression:write" ] } ], + "summary": "Remove an address from the suppression list", + "tags": [ + "Suppression" + ] + }, + "get": { + "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": "The suppressed address, URL-encoded.", + "in": "path", + "name": "email", + "required": true, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { + "description": "The suppressed address, URL-encoded.", + "format": "email", "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ] - }, - "required": false, - "name": "reason", - "in": "query" + } } ], "responses": { "200": { - "description": "Suppression list", "content": { - "application/json": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuppressionV1" + } + } + }, + "description": "The suppression record" + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SuppressionListResponse" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "400": { - "description": "Validation error", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — this address is not suppressed for the authenticated project." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "addSuppression", - "tags": [ - "Suppression" - ], - "summary": "Manually add an email to the suppression list", - "description": "The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers.\n\nRequires the `suppression:write` scope — Add and remove addresses on your suppression list.", + }, "security": [ { "ApiKeyAuth": [] @@ -20545,92 +20599,130 @@ }, { "OAuth2": [ - "suppression:write" + "suppression:read" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddSuppression" - } + "summary": "Check whether an address is suppressed", + "tags": [ + "Suppression" + ] + } + }, + "/api/v1/templates": { + "get": { + "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", + "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": "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": { - "201": { - "description": "Suppression added", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Suppression" + "$ref": "#/components/schemas/TemplateV1List" } } - } + }, + "description": "Template list" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/suppression/{email}": { - "get": { - "operationId": "checkSuppression", - "tags": [ - "Suppression" - ], - "summary": "Check whether an email is suppressed", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -20640,92 +20732,90 @@ }, { "OAuth2": [ - "suppression:read" + "templates:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "URL-encoded email address" - }, - "required": true, - "description": "URL-encoded email address", - "name": "email", - "in": "path" - } - ], + "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/TemplateV1Create" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "Suppression check result", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionCheckResponse" + "$ref": "#/components/schemas/TemplateV1" } } - } + }, + "description": "The created template" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "removeSuppression", - "tags": [ - "Suppression" - ], - "summary": "Remove an email from the suppression list", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -20735,87 +20825,115 @@ }, { "OAuth2": [ - "suppression:write" + "templates:write" ] } ], + "summary": "Create a template", + "tags": [ + "Templates" + ] + } + }, + "/api/v1/templates/{id}": { + "delete": { + "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": [ { - "schema": { - "type": "string", - "description": "URL-encoded email address" - }, + "description": "Resource id.", + "in": "path", + "name": "id", "required": true, - "description": "URL-encoded email address", - "name": "email", - "in": "path" + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { - "204": { - "description": "Suppression removed" - }, - "400": { - "description": "Validation error", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/TemplateV1Deleted" } } - } + }, + "description": "Template deleted" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$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 template with this id belongs to the authenticated project." + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "`conflict` — the template is still referenced by a workflow step or an active 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": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/suppressions": { - "get": { - "operationId": "v1ListSuppressions", - "tags": [ - "Suppression" - ], - "summary": "List suppressed addresses", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -20825,120 +20943,103 @@ }, { "OAuth2": [ - "suppression:read" + "templates:write" ] } ], + "summary": "Delete a template", + "tags": [ + "Templates" + ] + }, + "get": { + "description": "Fetch one template by id.\n\nRequires the `templates:read` scope — View your email templates.", + "operationId": "v1GetTemplate", "parameters": [ { + "description": "Resource id.", + "in": "path", + "name": "id", + "required": true, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "HARD_BOUNCE", - "COMPLAINT", - "MANUAL", - "UNSUBSCRIBE" - ], - "description": "Filter to one reason. Omit for every suppressed address." - }, - "required": false, - "description": "Filter to one reason. Omit for every suppressed address.", - "name": "reason", - "in": "query" + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Suppression list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionV1List" + "$ref": "#/components/schemas/TemplateV1" } } - } + }, + "description": "The template" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 template with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "v1CreateSuppression", - "tags": [ - "Suppression" - ], - "summary": "Suppress an address", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -20948,92 +21049,113 @@ }, { "OAuth2": [ - "suppression:write" + "templates:read" ] } ], + "summary": "Retrieve a template", + "tags": [ + "Templates" + ] + }, + "patch": { + "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.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Resource id.", + "format": "uuid", + "type": "string" + } + } + ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionV1Create" + "$ref": "#/components/schemas/TemplateV1Update" } } - } + }, + "required": true }, "responses": { - "201": { - "description": "The suppressed address", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionV1" + "$ref": "#/components/schemas/TemplateV1" } } - } + }, + "description": "The updated template" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 template with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/suppressions/{email}": { - "get": { - "operationId": "v1GetSuppression", - "tags": [ - "Suppression" - ], - "summary": "Check whether an address is suppressed", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -21043,103 +21165,117 @@ }, { "OAuth2": [ - "suppression:read" + "templates:write" ] } ], + "summary": "Update a template", + "tags": [ + "Templates" + ] + } + }, + "/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": [ { + "in": "query", + "name": "limit", + "required": false, "schema": { - "type": "string", - "format": "email", - "description": "The suppressed address, URL-encoded." - }, - "required": true, - "description": "The suppressed address, URL-encoded.", - "name": "email", - "in": "path" + "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": { "200": { - "description": "The suppression record", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionV1" + "$ref": "#/components/schemas/TopicListV1" } } - } + }, + "description": "One page of topics" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — this address is not suppressed for the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "v1DeleteSuppression", - "tags": [ - "Suppression" - ], - "summary": "Remove an address from the suppression list", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -21149,187 +21285,187 @@ }, { "OAuth2": [ - "suppression:write" + "topics:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "format": "email", - "description": "The suppressed address, URL-encoded." - }, - "required": true, - "description": "The suppressed address, URL-encoded.", - "name": "email", - "in": "path" - } - ], + "summary": "List topics", + "tags": [ + "Topics" + ] + }, + "post": { + "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": { - "description": "Address removed from the suppression list", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionV1Deleted" + "$ref": "#/components/schemas/TopicV1" } } - } + }, + "description": "The created topic" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/track": { - "post": { - "operationId": "trackEvent", - "tags": [ - "Events" - ], - "summary": "Track a custom event for a contact", - "description": "Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected.\n\nRequires the `events:write` scope — Record custom events for your contacts.", + }, "security": [ { "ApiKeyAuth": [] }, + { + "SessionAuth": [] + }, { "OAuth2": [ - "events:write" + "topics:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrackEvent" - } + "summary": "Create a topic", + "tags": [ + "Topics" + ] + } + }, + "/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": "The topic.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The topic.", + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Event tracked", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TrackEventResponse" + "$ref": "#/components/schemas/TopicV1" } } - } + }, + "description": "The topic" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/events": { - "get": { - "operationId": "v1ListEvents", - "tags": [ - "Events" - ], - "summary": "List events", - "description": "Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series.\n\nA cursor is bound to the filters it was issued under: pairing page 2's `next_cursor` with a different `event_name` answers 422 rather than returning a page that belongs to neither query.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", + }, "security": [ { "ApiKeyAuth": [] @@ -21339,116 +21475,102 @@ }, { "OAuth2": [ - "events:read" + "topics:read" ] } ], + "summary": "Retrieve a topic", + "tags": [ + "Topics" + ] + }, + "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": "The topic.", + "in": "path", + "name": "id", + "required": true, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Return only events with this exact name." - }, - "required": false, - "description": "Return only events with this exact name.", - "name": "event_name", - "in": "query" + "description": "The topic.", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TopicUpdateV1" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Event list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventV1List" + "$ref": "#/components/schemas/TopicV1" } } - } + }, + "description": "The updated topic" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "v1TrackEvent", - "tags": [ - "Events" - ], - "summary": "Record an event", - "description": "Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call.\n\n`contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event.\n\nReserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected with 422: they are written by Sendly's own pipeline and accepting them from a caller would corrupt the series segments read.\n\nThis endpoint does NOT require an `Idempotency-Key`. Events are append-only and the highest-volume write on the surface; a duplicate is a data-quality question for the caller, not a money-path hazard.\n\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.", + }, "security": [ { "ApiKeyAuth": [] @@ -21458,102 +21580,104 @@ }, { "OAuth2": [ - "events:write" + "topics:write" ] } ], + "summary": "Update a topic", + "tags": [ + "Topics" + ] + } + }, + "/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": "The topic.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The topic.", + "type": "string" + } + } + ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventTrackV1" + "$ref": "#/components/schemas/TopicSubscribeV1" } } - } + }, + "required": true }, "responses": { - "201": { - "description": "Event recorded", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventV1" + "$ref": "#/components/schemas/TopicSubscriptionV1" } } - } + }, + "description": "The resulting subscription" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — no contact with this id in the authenticated project.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/events/names": { - "get": { - "operationId": "v1ListEventNames", - "tags": [ - "Events" - ], - "summary": "List event names", - "description": "Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", + }, "security": [ { "ApiKeyAuth": [] @@ -21563,82 +21687,82 @@ }, { "OAuth2": [ - "events:read" + "topics:write" ] } ], + "summary": "Subscribe or unsubscribe a contact from a topic", + "tags": [ + "Topics" + ] + } + }, + "/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": { "200": { - "description": "Event names", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventNamesV1" + "$ref": "#/components/schemas/UsageV1" } } - } + }, + "description": "Current usage" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/events/stats": { - "get": { - "operationId": "v1GetEventStats", - "tags": [ - "Events" - ], - "summary": "Retrieve event counts", - "description": "Per-name event counts over a bounded window, most frequent first.\n\nThe window defaults to the last 30 days and never reaches further back than 90: this is a GROUP BY over the highest-volume table in the system, and an all-time answer is not one it can keep giving at scale. A wider request is narrowed rather than refused, and the `window` field states the range actually covered.\n\nRequires the `events:read` scope — View the custom events your application has recorded.", + }, "security": [ { "ApiKeyAuth": [] @@ -21648,112 +21772,94 @@ }, { "OAuth2": [ - "events:read" + "usage:read" ] } ], + "summary": "Retrieve current usage and limits", + "tags": [ + "Usage" + ] + } + }, + "/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": "The validation run.", + "in": "path", + "name": "id", + "required": true, "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." - }, - "required": false, - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", - "name": "from", - "in": "query" - }, - { - "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "End of the window (ISO 8601). Defaults to now." - }, - "required": false, - "description": "End of the window (ISO 8601). Defaults to now.", - "name": "to", - "in": "query" + "description": "The validation run.", + "type": "string" + } } ], "responses": { "200": { - "description": "Event counts", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventStatsV1" + "$ref": "#/components/schemas/EmailValidationRunV1" } } - } + }, + "description": "The run" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/analytics/timeseries": { - "get": { - "operationId": "v1GetAnalyticsTimeseries", - "tags": [ - "Analytics" - ], - "summary": "Retrieve the daily email time series", - "description": "Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", + }, "security": [ { "ApiKeyAuth": [] @@ -21763,112 +21869,132 @@ }, { "OAuth2": [ - "analytics:read" + "validation:read" ] } ], + "summary": "Retrieve a validation run", + "tags": [ + "Validation" + ] + } + }, + "/api/v1/validation-runs/{id}/results": { + "get": { + "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": "The validation run.", + "in": "path", + "name": "id", + "required": true, "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." - }, + "description": "The validation run.", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", "required": false, - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", - "name": "from", - "in": "query" + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } }, { + "description": "Opaque cursor from a previous response's `next_cursor`.", + "in": "query", + "name": "after", + "required": false, "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "End of the window (ISO 8601). Defaults to now." - }, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "Return only results with this verdict — `undeliverable` is the usual filter.", + "in": "query", + "name": "verdict", "required": false, - "description": "End of the window (ISO 8601). Defaults to now.", - "name": "to", - "in": "query" + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/EmailValidationVerdictV1" + }, + { + "description": "Return only results with this verdict — `undeliverable` is the usual filter." + } + ] + } } ], "responses": { "200": { - "description": "Daily time series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsTimeseriesV1" + "$ref": "#/components/schemas/EmailValidationResultListV1" } } - } + }, + "description": "One page of results" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/analytics/campaigns": { - "get": { - "operationId": "v1GetCampaignAnalytics", - "tags": [ - "Analytics" - ], - "summary": "Retrieve campaign totals and engagement", - "description": "Campaign counts plus average open and click rates.\n\n`total` and `active` count campaigns CREATED in the window; `completed` counts campaigns SENT in it — so a campaign created earlier and sent inside the window appears only in `completed`. Rates are percentages to one decimal place, averaged over the campaigns sent in the window.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", + }, "security": [ { "ApiKeyAuth": [] @@ -21878,112 +22004,106 @@ }, { "OAuth2": [ - "analytics:read" + "validation:read" ] } ], + "summary": "List a validation run's results", + "tags": [ + "Validation" + ] + } + }, + "/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": [ { - "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." - }, + "in": "query", + "name": "limit", "required": false, - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", - "name": "from", - "in": "query" + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer" + } }, { - "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "End of the window (ISO 8601). Defaults to now." - }, + "description": "Opaque cursor from a previous response's `next_cursor`.", + "in": "query", + "name": "after", "required": false, - "description": "End of the window (ISO 8601). Defaults to now.", - "name": "to", - "in": "query" + "schema": { + "description": "Opaque cursor from a previous response's `next_cursor`.", + "minLength": 1, + "type": "string" + } } ], "responses": { "200": { - "description": "Campaign statistics", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsCampaignStatsV1" + "$ref": "#/components/schemas/WebhookV1List" } } - } + }, + "description": "Webhook list" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/analytics/top-campaigns": { - "get": { - "operationId": "v1ListTopCampaigns", - "tags": [ - "Analytics" - ], - "summary": "List the best-performing campaigns", - "description": "Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page.\n\n`from` defaults to 30 days ago and is clamped to at most 90 days back; `to` defaults to now. A wider request is narrowed rather than refused, and the `window` field states the range actually covered — read it before comparing two responses.\n\nRequires the `analytics:read` scope — View your sending analytics and engagement metrics.", + }, "security": [ { "ApiKeyAuth": [] @@ -21993,123 +22113,90 @@ }, { "OAuth2": [ - "analytics:read" + "webhooks:read" ] } ], - "parameters": [ - { - "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back." - }, - "required": false, - "description": "Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back.", - "name": "from", - "in": "query" - }, - { - "schema": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "End of the window (ISO 8601). Defaults to now." - }, - "required": false, - "description": "End of the window (ISO 8601). Defaults to now.", - "name": "to", - "in": "query" + "summary": "List webhooks", + "tags": [ + "Webhooks" + ] + }, + "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" + } + } }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 50, - "default": 10 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], + "required": true + }, "responses": { - "200": { - "description": "Ranked campaigns", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsTopCampaignsV1" + "$ref": "#/components/schemas/WebhookV1Created" } } - } + }, + "description": "The created webhook and its one-time signing secret" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/deliverability/diagnose": { - "get": { - "operationId": "v1DiagnoseDeliverability", - "tags": [ - "Deliverability" - ], - "summary": "Diagnose why mail from a domain is not arriving", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -22119,119 +22206,105 @@ }, { "OAuth2": [ - "deliverability:read" + "webhooks:write" ] } ], + "summary": "Create a webhook", + "tags": [ + "Webhooks" + ] + } + }, + "/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": [ { - "schema": { - "type": "string", - "minLength": 3, - "maxLength": 253, - "description": "A sending domain in this project, e.g. `example.com`." - }, + "description": "Resource id.", + "in": "path", + "name": "id", "required": true, - "description": "A sending domain in this project, e.g. `example.com`.", - "name": "domain", - "in": "query" - }, - { "schema": { - "type": "string", - "format": "email", - "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." - }, - "required": false, - "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.", - "name": "address", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 30, - "description": "How far back the delivery counters look. 1–30 days; defaults to 7." - }, - "required": false, - "description": "How far back the delivery counters look. 1–30 days; defaults to 7.", - "name": "window_days", - "in": "query" + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "The diagnosis, with findings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeliverabilityDiagnosisV1" + "$ref": "#/components/schemas/WebhookV1Deleted" } } - } + }, + "description": "Webhook deleted" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 webhook with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/deliverability/domains": { - "get": { - "operationId": "v1ListRecipientDomainStats", - "tags": [ - "Deliverability" - ], - "summary": "Delivery outcomes per recipient domain", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -22241,130 +22314,103 @@ }, { "OAuth2": [ - "deliverability:read" + "webhooks:write" ] } ], + "summary": "Delete a webhook", + "tags": [ + "Webhooks" + ] + }, + "get": { + "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": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 30, - "description": "How far back to read. 1-30 days; defaults to 30, which is the window the job maintains." - }, - "required": false, - "description": "How far back to read. 1-30 days; defaults to 30, which is the window the job maintains.", - "name": "days", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253, - "description": "Restrict to one recipient domain." - }, - "required": false, - "description": "Restrict to one recipient domain.", - "name": "domain", - "in": "query" + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Cursor-paginated recipient-domain rollup", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipientDomainStatsV1List" + "$ref": "#/components/schemas/WebhookV1" } } - } + }, + "description": "The webhook" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "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`." }, - "403": { - "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": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/deliverability/dmarc": { - "get": { - "operationId": "v1ListDmarcReports", - "tags": [ - "Deliverability" - ], - "summary": "DMARC aggregate reports for your domains", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -22374,130 +22420,113 @@ }, { "OAuth2": [ - "deliverability:read" + "webhooks:read" ] } ], + "summary": "Retrieve a webhook", + "tags": [ + "Webhooks" + ] + }, + "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": [ { + "description": "Resource id.", + "in": "path", + "name": "id", + "required": true, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Opaque cursor from a previous response's `next_cursor`." - }, - "required": false, - "description": "Opaque cursor from a previous response's `next_cursor`.", - "name": "after", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 180, - "description": "How far back to read, by the report's window start. 1-180 days; defaults to 30." - }, - "required": false, - "description": "How far back to read, by the report's window start. 1-180 days; defaults to 30.", - "name": "days", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253, - "description": "Restrict to reports about one of your domains." - }, - "required": false, - "description": "Restrict to reports about one of your domains.", - "name": "domain", - "in": "query" + "description": "Resource id.", + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookV1Update" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Cursor-paginated DMARC aggregate reports", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DmarcReportV1List" + "$ref": "#/components/schemas/WebhookV1" } } - } + }, + "description": "The updated webhook" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 webhook with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/usage": { - "get": { - "operationId": "v1GetUsage", - "tags": [ - "Usage" - ], - "summary": "Retrieve current usage and limits", - "description": "Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling.\n\nEvery figure is read from an enforcement path, so what this reports and what refuses a send cannot disagree. Correspondingly, nothing else is published — there is no billing period, invoice total or non-email meter here, because the platform meters none of those.\n\nTwo caveats worth reading before you alert on these numbers:\n\n- The windows differ. The monthly counters roll over on the SERVER's calendar month; the daily counter buckets on the UTC date. The two therefore reset at different instants.\n- `monthly.limit` is null once a subscription makes sending metered rather than capped, and also when an operator has set per-category limits — in that case the caps live in `monthly.categories[*].limit`.\n\nRequires the `usage:read` scope — View your usage totals and billing limits.", + }, "security": [ { "ApiKeyAuth": [] @@ -22507,82 +22536,105 @@ }, { "OAuth2": [ - "usage:read" + "webhooks:write" ] } ], + "summary": "Update a webhook", + "tags": [ + "Webhooks" + ] + } + }, + "/api/v1/webhooks/{id}/rotate-secret": { + "post": { + "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" + } + } + ], "responses": { "200": { - "description": "Current usage", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UsageV1" + "$ref": "#/components/schemas/WebhookV1SecretRotated" } } - } + }, + "description": "The new signing secret and the moment the previous one stops verifying" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 webhook with this id belongs to the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/projects": { - "get": { - "operationId": "v1GetProject", - "tags": [ - "Projects" - ], - "summary": "Retrieve the authenticated project", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -22592,92 +22644,106 @@ }, { "OAuth2": [ - "projects:read" + "webhooks:write" ] } ], + "summary": "Rotate a webhook signing secret", + "tags": [ + "Webhooks" + ] + } + }, + "/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": [ + { + "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": { - "description": "The authenticated project", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectV1" + "$ref": "#/components/schemas/WorkflowV1List" } } - } + }, + "description": "Workflow list" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "`resource_not_found` — the project was deleted between authentication and this read.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/mailboxes": { - "get": { - "operationId": "listMailboxes", - "tags": [ - "Mailboxes" - ], - "summary": "List mailboxes", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -22685,237 +22751,200 @@ { "SessionAuth": [] }, - { - "OAuth2": [ - "mailboxes:read" - ] - } - ], + { + "OAuth2": [ + "workflows:read" + ] + } + ], + "summary": "List workflows", + "tags": [ + "Workflows" + ] + }, + "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": { - "description": "Mailbox list", + "201": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Mailbox" - } - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WorkflowV1" } } - } + }, + "description": "Workflow created" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "createMailbox", - "tags": [ - "Mailboxes" - ], - "summary": "Create a mailbox", - "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.", + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "workflows:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMailboxBody" - } + "summary": "Create 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", + "parameters": [ + { + "description": "Workflow execution id.", + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "description": "Workflow execution id.", + "format": "uuid", + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "Mailbox provisioned", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/Mailbox" - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/WorkflowExecutionV1" } } - } + }, + "description": "Cancelled execution" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no execution with this id in the authenticated project." }, - "409": { - "description": "The address already exists, the domain is not verified, or the project is at its 10-mailbox limit.", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/mailboxes/{id}": { - "get": { - "operationId": "getMailbox", - "tags": [ - "Mailboxes" - ], - "summary": "Get a mailbox", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -22925,245 +22954,221 @@ }, { "OAuth2": [ - "mailboxes:read" + "workflows:write" ] } ], + "summary": "Cancel a workflow execution", + "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", "parameters": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Mailbox with connection settings", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/MailboxDetail" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WorkflowDeletedV1" } } - } + }, + "description": "Workflow deleted" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "description": "Forbidden — insufficient permissions or project disabled", + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "`resource_not_found` — no workflow with this id in the authenticated project." + }, + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`conflict` — the workflow still has running executions." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "delete": { - "operationId": "deleteMailbox", - "tags": [ - "Mailboxes" - ], - "summary": "Delete a mailbox", - "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.", + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "workflows:write" ] } ], + "summary": "Delete a workflow", + "tags": [ + "Workflows" + ] + }, + "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", "parameters": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "Mailbox deleted", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "deleted": { - "type": "boolean", - "enum": [ - true - ] - } - }, - "required": [ - "deleted" - ] - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WorkflowV1" } } - } + }, + "description": "Workflow" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no workflow with this id in the authenticated project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/mailboxes/{id}/drafts": { - "post": { - "operationId": "draftMailboxMessage", - "tags": [ - "Mailboxes" - ], - "summary": "Draft a message with AI", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -23173,174 +23178,123 @@ }, { "OAuth2": [ - "mailboxes:read" + "workflows:read" ] } ], + "summary": "Retrieve a workflow", + "tags": [ + "Workflows" + ] + }, + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DraftMailboxMessage" + "$ref": "#/components/schemas/WorkflowUpdateV1" } } - } + }, + "required": true }, "responses": { "200": { - "description": "A draft. Nothing was sent.", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "subject": { - "type": [ - "string", - "null" - ], - "description": "Suggested subject, or null." - }, - "body": { - "type": [ - "string", - "null" - ], - "description": "Suggested plain-text body, or null." - }, - "subjects": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Alternative subject lines (`subject` mode); empty otherwise." - }, - "sent": { - "type": "boolean", - "enum": [ - false - ], - "description": "Always false. Reported rather than assumed, so a draft cannot be mistaken for a send." - } - }, - "required": [ - "subject", - "body", - "subjects", - "sent" - ] - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WorkflowV1" } } - } + }, + "description": "Updated workflow" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no workflow with this id in the authenticated project." }, - "404": { - "description": "Resource not found", + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`conflict` — the trigger cannot be changed while executions are running." }, - "429": { - "description": "Rate limit or billing limit exceeded", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, - "500": { - "description": "Internal server error", + "429": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, - "502": { - "description": "The drafting model was unreachable or returned nothing usable.", + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/mailboxes/{id}/messages": { - "post": { - "operationId": "sendMailboxMessage", - "tags": [ - "Mailboxes" - ], - "summary": "Send a message from a mailbox", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -23350,181 +23304,115 @@ }, { "OAuth2": [ - "mailboxes:send" + "workflows:write" ] } ], + "summary": "Update a workflow", + "tags": [ + "Workflows" + ] + } + }, + "/api/v1/workflows/{id}/clone": { + "post": { + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ComposeMailboxMessage" + "$ref": "#/components/schemas/WorkflowCloneV1" } } - } + }, + "required": false }, "responses": { "201": { - "description": "Message submitted", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "submitted": { - "type": "boolean", - "enum": [ - true - ] - }, - "conversationId": { - "type": "string", - "format": "uuid", - "description": "The conversation this send started. Replies thread onto it." - }, - "messageId": { - "type": "string", - "format": "uuid", - "description": "The stored outbound message." - } - }, - "required": [ - "submitted", - "conversationId", - "messageId" - ] - } - }, - "required": [ - "success", - "data" - ] - } - } - } - }, - "400": { - "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/WorkflowV1" } } - } + }, + "description": "The cloned workflow" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, "404": { - "description": "Resource not found", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no workflow with this id in the authenticated project." }, "422": { - "description": "The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`).", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "The mail server refused the submission. Nothing was sent.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "Message screening could not reach a verdict. Nothing was sent; retry shortly.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/mailboxes/{id}/app-passwords": { - "get": { - "operationId": "listAppPasswords", - "tags": [ - "Mailboxes" - ], - "summary": "List a mailbox's app passwords", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -23534,559 +23422,505 @@ }, { "OAuth2": [ - "mailboxes:read" + "workflows:write" ] } ], + "summary": "Clone a workflow", + "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", "parameters": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "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": { "200": { - "description": "App password list", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AppPassword" - } - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WorkflowExecutionV1List" } } - } + }, + "description": "Execution list" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no workflow with this id in the authenticated project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "createAppPassword", - "tags": [ - "Mailboxes" - ], - "summary": "Create an app password", - "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.", + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "workflows:read" ] } ], + "summary": "List a workflow's executions", + "tags": [ + "Workflows" + ] + }, + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateAppPassword" + "$ref": "#/components/schemas/WorkflowExecutionStartV1" } } - } + }, + "required": true }, "responses": { "201": { - "description": "App password created; the secret is behind the one-time link", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "$ref": "#/components/schemas/AppPasswordReveal" - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WorkflowExecutionV1" } } - } + }, + "description": "Execution started" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no such workflow, or no such contact in this project." }, - "404": { - "description": "Resource not found", + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`conflict` — the contact already has an execution and re-entry is not allowed." + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/mailboxes/{id}/app-passwords/{passwordId}": { - "delete": { - "operationId": "revokeAppPassword", - "tags": [ - "Mailboxes" - ], - "summary": "Revoke an app password", - "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.", + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { "OAuth2": [ - "mailboxes:write" + "workflows:write" ] } ], + "summary": "Start a workflow for a contact", + "tags": [ + "Workflows" + ] + } + }, + "/api/v1/workflows/{id}/graph": { + "get": { + "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": [ { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "format": "uuid" - }, "required": true, - "name": "passwordId", - "in": "path" + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "App password revoked", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "enum": [ - true - ] - }, - "data": { - "type": "object", - "properties": { - "revoked": { - "type": "boolean", - "enum": [ - true - ] - } - }, - "required": [ - "revoked" - ] - } - }, - "required": [ - "success", - "data" - ] + "$ref": "#/components/schemas/WorkflowGraphV1" } } - } + }, + "description": "The workflow's graph" }, - "400": { - "description": "Validation error", + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, - "401": { - "description": "Unauthorized — missing or invalid auth", + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "403": { - "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` — no workflow with this id in the authenticated project." }, - "404": { - "description": "Resource not found", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "Rate limit or billing limit exceeded", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/users/me/projects": { - "post": { - "operationId": "createProject", - "tags": [ - "Projects" - ], - "summary": "Create a project", - "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.", + }, "security": [ + { + "ApiKeyAuth": [] + }, { "SessionAuth": [] }, { - "OAuth2": [ - "projects:write" - ] + "OAuth2": [ + "workflows:read" + ] + } + ], + "summary": "Retrieve a workflow's step graph", + "tags": [ + "Workflows" + ] + }, + "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.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "sesRegion": { - "type": "string", - "enum": [ - "us-east-1", - "us-west-2", - "eu-west-1" - ], - "description": "AWS SES region for the project. Once a domain is added the region is locked and cannot be changed." - } - }, - "required": [ - "name" - ] + "$ref": "#/components/schemas/WorkflowGraphReplaceV1" } } - } + }, + "required": true }, "responses": { - "201": { - "description": "Project created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectRecord" - } - } - } - }, - "400": { - "description": "Validation error", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/WorkflowGraphV1" } } - } + }, + "description": "The graph as it now stands" }, "401": { - "description": "Unauthorized — missing or invalid auth", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "Forbidden — insufficient permissions or project disabled", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`scope_missing`, `project_access_denied`, or `project_disabled`." }, - "422": { - "description": "Validation failed — request body or query parameters did not match the schema", + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`resource_not_found` — no workflow with this id in the authenticated project." }, - "429": { - "description": "Rate limit or billing limit exceeded", + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow." }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/api/verify": { - "post": { - "operationId": "verifyEmailAddress", - "tags": [ - "Verify" - ], - "summary": "Validate an email address", - "description": "Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier.", - "security": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyEmail" - } - } - } - }, - "responses": { - "200": { - "description": "Verification result", + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/VerifyEmailResponse" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, - "400": { - "description": "Validation error", + "429": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "Internal server error", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/topics": { - "get": { - "operationId": "v1ListTopics", - "tags": [ - "Topics" - ], - "summary": "List topics", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -24096,111 +23930,105 @@ }, { "OAuth2": [ - "topics:read" + "workflows:write" ] } ], + "summary": "Replace a workflow's step graph", + "tags": [ + "Workflows" + ] + } + }, + "/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.", + "in": "path", + "name": "id", + "required": true, "schema": { + "description": "Workflow id.", + "format": "uuid", "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": [ - "boolean", - "null" - ] - }, - "required": false, - "name": "include_archived", - "in": "query" + } } ], "responses": { "200": { - "description": "One page of topics", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TopicListV1" + "$ref": "#/components/schemas/WorkflowStateChangeV1" } } - } + }, + "description": "The workflow, and the number of runs this call cancelled" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 workflow with this id in the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "post": { - "operationId": "v1CreateTopic", - "tags": [ - "Topics" - ], - "summary": "Create a topic", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -24210,92 +24038,105 @@ }, { "OAuth2": [ - "topics:write" + "workflows:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TopicCreateV1" - } + "summary": "Pause a workflow and cancel its running executions", + "tags": [ + "Workflows" + ] + } + }, + "/api/v1/workflows/{id}/resume": { + "post": { + "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.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "The created topic", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TopicV1" + "$ref": "#/components/schemas/WorkflowStateChangeV1" } } - } + }, + "description": "The workflow, with `cancelled_executions` always 0" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 workflow with this id in the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - } - }, - "/api/v1/topics/{id}": { - "get": { - "operationId": "v1GetTopic", - "tags": [ - "Topics" - ], - "summary": "Retrieve a topic", - "description": "Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each.", + }, "security": [ { "ApiKeyAuth": [] @@ -24305,92 +24146,119 @@ }, { "OAuth2": [ - "topics:read" + "workflows:write" ] } ], + "summary": "Resume a paused workflow", + "tags": [ + "Workflows" + ] + } + }, + "/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\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": [ { - "schema": { - "type": "string", - "description": "The topic." - }, - "required": true, - "description": "The topic.", + "description": "Workflow id.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "description": "Workflow id.", + "format": "uuid", + "type": "string" + } + }, + { + "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time.", + "in": "query", + "name": "from", + "required": false, + "schema": { + "description": "Only count executions started at or after this instant (ISO 8601). Defaults to all time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + } } ], "responses": { "200": { - "description": "The topic", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TopicV1" + "$ref": "#/components/schemas/WorkflowStatsV1" } } - } + }, + "description": "Workflow statistics" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials." }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "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 workflow with this id in the authenticated project." }, "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`validation_error` — query, path, or body parameters did not match the schema." }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers." }, "500": { - "description": "`internal_error`.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "`internal_error`." } - } - }, - "patch": { - "operationId": "v1UpdateTopic", - "tags": [ - "Topics" - ], - "summary": "Update a topic", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -24400,104 +24268,135 @@ }, { "OAuth2": [ - "topics:write" + "workflows:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The topic." - }, - "required": true, - "description": "The topic.", - "name": "id", - "in": "path" - } - ], + "summary": "Retrieve workflow statistics", + "tags": [ + "Workflows" + ] + } + }, + "/api/verify": { + "post": { + "description": "Open endpoint (no auth required) that checks an email for syntax, MX records, disposable domains, and plus-addressing. Used by the marketing site verifier.", + "operationId": "verifyEmailAddress", "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TopicUpdateV1" + "$ref": "#/components/schemas/VerifyEmail" } } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailResponse" + } + } + }, + "description": "Verification result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal server error" } }, + "security": [], + "summary": "Validate an email address", + "tags": [ + "Verify" + ] + } + }, + "/api/webhooks": { + "get": { + "description": "List all user-managed outbound webhooks for the auth'd project (secrets are not returned).\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", + "operationId": "listWebhooks", "responses": { "200": { - "description": "The updated topic", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TopicV1" + "$ref": "#/components/schemas/WebhookListResponse" } } - } + }, + "description": "Webhook list" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/topics/{id}/subscriptions": { - "post": { - "operationId": "v1SetTopicSubscription", - "tags": [ - "Topics" - ], - "summary": "Subscribe or unsubscribe a contact from a topic", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -24507,104 +24406,90 @@ }, { "OAuth2": [ - "topics:write" + "webhooks:read" ] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The topic." - }, - "required": true, - "description": "The topic.", - "name": "id", - "in": "path" - } - ], + "summary": "List user webhooks", + "tags": [ + "Webhooks" + ] + }, + "post": { + "description": "Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + "operationId": "createWebhook", "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TopicSubscribeV1" + "$ref": "#/components/schemas/CreateWebhook" } } - } + }, + "required": true }, "responses": { - "200": { - "description": "The resulting subscription", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TopicSubscriptionV1" + "$ref": "#/components/schemas/WebhookCreateResponse" } } - } + }, + "description": "Webhook created" }, - "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", + "400": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Validation error" }, - "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", + "401": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "403": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/contacts/{id}/topics": { - "get": { - "operationId": "v1GetContactTopicPreferences", - "tags": [ - "Topics" - ], - "summary": "Get a contact's topic preferences", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -24614,94 +24499,103 @@ }, { "OAuth2": [ - "topics:read" + "webhooks:write" ] } ], + "summary": "Create a webhook", + "tags": [ + "Webhooks" + ] + } + }, + "/api/webhooks/{id}": { + "delete": { + "description": "Hard-delete a webhook. Cascades to all WebhookCall rows.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + "operationId": "deleteWebhook", "parameters": [ { - "schema": { - "type": "string", - "description": "The contact." - }, - "required": true, - "description": "The contact.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { - "description": "The contact's preferences", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContactTopicPreferencesV1" + "$ref": "#/components/schemas/SuccessEmpty" } } - } + }, + "description": "Webhook deleted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/email-validations": { - "post": { - "operationId": "v1ValidateEmails", - "tags": [ - "Validation" - ], - "summary": "Validate a batch of email addresses", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -24711,92 +24605,101 @@ }, { "OAuth2": [ - "validation:write" + "webhooks:write" ] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmailValidationBatchRequestV1" - } + "summary": "Delete a webhook", + "tags": [ + "Webhooks" + ] + }, + "get": { + "description": "Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", + "operationId": "getWebhook", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" } } - }, + ], "responses": { "200": { - "description": "One verdict per address, in the order they were given", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailValidationBatchV1" + "$ref": "#/components/schemas/WebhookGetResponse" } } - } + }, + "description": "Webhook" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/lists/{id}/validation-runs": { - "post": { - "operationId": "v1StartListValidationRun", - "tags": [ - "Validation" - ], - "summary": "Validate every address on a list", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -24806,94 +24709,111 @@ }, { "OAuth2": [ - "validation:write" + "webhooks:read" ] } ], + "summary": "Get a webhook", + "tags": [ + "Webhooks" + ] + }, + "patch": { + "description": "Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + "operationId": "updateWebhook", "parameters": [ { - "schema": { - "type": "string", - "description": "The list to validate." - }, - "required": true, - "description": "The list to validate.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhook" + } + } + }, + "required": true + }, "responses": { - "202": { - "description": "The run, accepted and queued", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailValidationRunV1" + "$ref": "#/components/schemas/WebhookGetResponse" } } - } + }, + "description": "Webhook updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - } - } - } - }, - "/api/v1/validation-runs/{id}": { - "get": { - "operationId": "v1GetValidationRun", - "tags": [ - "Validation" - ], - "summary": "Retrieve a validation run", - "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.", + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal server error" + } + }, "security": [ { "ApiKeyAuth": [] @@ -24903,94 +24823,122 @@ }, { "OAuth2": [ - "validation:read" + "webhooks:write" ] } ], + "summary": "Update a webhook", + "tags": [ + "Webhooks" + ] + } + }, + "/api/webhooks/{id}/calls": { + "get": { + "description": "Cursor-paginated list of recent delivery attempts for a single webhook.\n\nRequires the `webhooks:read` scope — View your webhook endpoints and their delivery history.", + "operationId": "listWebhookCalls", "parameters": [ { - "schema": { - "type": "string", - "description": "The validation run." - }, - "required": true, - "description": "The validation run.", + "in": "path", "name": "id", - "in": "path" + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { "200": { - "description": "The run", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailValidationRunV1" + "$ref": "#/components/schemas/WebhookCallsListResponse" } } - } + }, + "description": "Webhook call history" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" } - } - } - }, - "/api/v1/validation-runs/{id}/results": { - "get": { - "operationId": "v1ListValidationRunResults", - "tags": [ - "Validation" - ], - "summary": "List a validation run's results", - "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.", + }, "security": [ { "ApiKeyAuth": [] @@ -25000,120 +24948,209 @@ }, { "OAuth2": [ - "validation:read" + "webhooks:read" ] } ], + "summary": "List recent webhook calls", + "tags": [ + "Webhooks" + ] + } + }, + "/api/webhooks/{id}/rotate-secret": { + "post": { + "description": "Generate a new shared secret. Returns the new plaintext secret exactly once.\n\nRequires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints.", + "operationId": "rotateWebhookSecret", "parameters": [ { - "schema": { - "type": "string", - "description": "The validation run." - }, - "required": true, - "description": "The validation run.", + "in": "path", "name": "id", - "in": "path" - }, - { + "required": true, "schema": { + "format": "uuid", "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/EmailValidationVerdictV1" - }, - { - "description": "Return only results with this verdict — `undeliverable` is the usual filter." - } - ] - }, - "required": false, - "description": "Return only results with this verdict — `undeliverable` is the usual filter.", - "name": "verdict", - "in": "query" + } } ], "responses": { "200": { - "description": "One page of results", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailValidationResultListV1" + "$ref": "#/components/schemas/WebhookRotateSecretResponse" } } - } + }, + "description": "Secret rotated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Validation error" }, "401": { - "description": "`invalid_api_key` or `invalid_session` — missing or invalid credentials.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Unauthorized — missing or invalid auth" }, "403": { - "description": "`scope_missing`, `project_access_denied`, or `project_disabled`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Forbidden — insufficient permissions or project disabled" }, - "422": { - "description": "`validation_error` — query, path, or body parameters did not match the schema.", + "404": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Resource not found" }, "429": { - "description": "`rate_limited` — see `Retry-After` and the `RateLimit` headers.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Rate limit or billing limit exceeded" }, "500": { - "description": "`internal_error`.", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/Error" } } - } + }, + "description": "Internal server error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "SessionAuth": [] + }, + { + "OAuth2": [ + "webhooks:write" + ] } - } + ], + "summary": "Rotate the webhook signing secret", + "tags": [ + "Webhooks" + ] } } }, + "servers": [ + { + "url": "https://api.sendly.now" + } + ], + "tags": [ + { + "description": "Send transactional email and inspect deliveries.", + "name": "Emails" + }, + { + "description": "Bulk sends to a list, segment, or filtered audience. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors).", + "name": "Campaigns" + }, + { + "description": "Saved audiences — a `DYNAMIC` filter re-evaluated on read, or a `STATIC` membership list. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors).", + "name": "Segments" + }, + { + "description": "Manage subscribers and per-contact custom data.", + "name": "Contacts" + }, + { + "description": "Self-serve list membership. These two endpoints accept sending-only keys so they can back a public subscribe/unsubscribe form; list management itself is dashboard-only.", + "name": "Lists" + }, + { + "description": "Register sending domains and manage SES verification.", + "name": "Domains" + }, + { + "description": "Reusable email templates referenced by send operations.", + "name": "Templates" + }, + { + "description": "User-managed outbound webhooks for email and contact events.", + "name": "Webhooks" + }, + { + "description": "Project-scoped sending/API credentials. All four operations are documented; minting and rotation answer with a one-time reveal link rather than the secret, so a delegated caller never receives a key it could use.", + "name": "API Keys" + }, + { + "description": "Project-scoped suppression list. Hard bounces and complaints land here automatically.", + "name": "Suppression" + }, + { + "description": "Event-triggered automations and the contact runs through them. Served by the `/api/v1` surface (bare payloads, RFC 9457 errors).", + "name": "Workflows" + }, + { + "description": "Track custom contact events from your application.", + "name": "Events" + }, + { + "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" + }, + { + "description": "The project a credential is scoped to, and creating new ones. Configuring an existing project stays a dashboard action.", + "name": "Projects" + }, + { + "description": "Real receiving mailboxes on your verified domains. Creating one changes how that domain's mail is routed; the messages a mailbox receives are not part of this API.", + "name": "Mailboxes" + }, + { + "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/src/__tests__/contract.test.ts b/src/__tests__/contract.test.ts index 3c6e448..82591b2 100644 --- a/src/__tests__/contract.test.ts +++ b/src/__tests__/contract.test.ts @@ -600,11 +600,11 @@ describe("OpenAPI contract", () => { // The v1 list envelope is `{ data, has_more, next_cursor }`; any operation // answering with it should be walkable without the caller managing cursors. // - // Two envelopes exist. Topics and validation results answer - // `{ data, cursor, has_more }` — the next page arrives under `cursor`, and the - // request parameter is `cursor` rather than `after`. Those resources drive the - // loop themselves instead of using the shared helper, so this guard looks for - // the generator, not for the helper. + // ONE envelope, as of the 1.1 contract. Topics and validation results used to + // answer `{ data, cursor, has_more }` and take `cursor`, which meant the shared + // page-walker sent a parameter they ignored and read a field they never returned. + // Detection is still by SHAPE rather than by an endpoint list, so a resource that + // reintroduces the second dialect is caught rather than assumed away. const cursorListOps = new Set(); for (const [path, methods] of Object.entries(spec.paths)) { if (!path.startsWith("/api/v1")) continue; @@ -612,7 +612,7 @@ describe("OpenAPI contract", () => { const name = schema?.$ref?.split("/").pop(); const resolved = name ? spec.components?.schemas?.[name] : undefined; const props = resolved?.properties ?? schema?.properties; - if (props && "data" in props && "has_more" in props && ("next_cursor" in props || "cursor" in props)) { + if (props && "data" in props && "has_more" in props && "next_cursor" in props) { cursorListOps.add(`GET ${path}`); } } diff --git a/src/__tests__/topics.test.ts b/src/__tests__/topics.test.ts index b537b9c..2c81feb 100644 --- a/src/__tests__/topics.test.ts +++ b/src/__tests__/topics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import type { TopicV1 } from "../types"; -import { getCall, getCallBody, jsonResponse, makeClient } from "./helpers"; +import { cursorPage, getCall, getCallBody, jsonResponse, makeClient } from "./helpers"; function topic(id: string): TopicV1 { // eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter @@ -10,12 +10,12 @@ function topic(id: string): TopicV1 { /** * One page of the topics list envelope. * - * Deliberately not `helpers.cursorPage`: that builds the `next_cursor` field - * the rest of v1 answers with, and topics answer `cursor` instead. + * `helpers.cursorPage`, the same builder every other v1 list test uses. Through + * 1.0 this file had its own, because topics answered the next page under + * `cursor` where the rest of v1 answers `next_cursor`. A local fixture is how a + * second dialect stays invisible, so this one is gone rather than updated. */ -function topicPage(data: TopicV1[], cursor: string | null): Response { - return jsonResponse(200, { data, has_more: cursor !== null, cursor }); -} +const topicPage = cursorPage; describe("topics resource (/api/v1)", () => { test("list GETs /api/v1/topics and resolves the bare page, envelope and all", async () => { @@ -32,18 +32,18 @@ describe("topics resource (/api/v1)", () => { expect(page.data[0]?.id).toBe("top_1"); }); - test("list serializes limit, cursor and include_archived", async () => { + test("list serializes limit, after and include_archived", async () => { const { client, fetchMock } = makeClient(); fetchMock.mockResolvedValue(topicPage([], null)); - await client.topics.list({ limit: 10, cursor: "cur_top", include_archived: true }); + await client.topics.list({ limit: 10, after: "cur_top", include_archived: true }); const { url } = getCall(fetchMock); expect(url).toContain("limit=10"); - expect(url).toContain("cursor=cur_top"); + expect(url).toContain("after=cur_top"); expect(url).toContain("include_archived=true"); - // The v1 pagination parameter everywhere else; topics must not emit it. - expect(url).not.toContain("after="); + // `cursor` was this endpoint's own parameter through 1.0 and is not one now. + expect(url).not.toContain("cursor="); }); test("create POSTs /api/v1/topics with the key and opt-in default", async () => { @@ -111,7 +111,7 @@ describe("topics resource (/api/v1)", () => { expect(fetchMock.mock.calls).toHaveLength(2); }); - test("listAll follows the `cursor` parameter this endpoint names, never `after`", async () => { + test("listAll follows `after`, the one v1 pagination parameter", async () => { const { client, fetchMock } = makeClient(); fetchMock .mockResolvedValueOnce(topicPage([topic("top_1")], "cur_2")) @@ -123,8 +123,8 @@ describe("topics resource (/api/v1)", () => { expect(seen).toEqual(["top_1", "top_2"]); const second = getCall(fetchMock, 1).url; - expect(second).toContain("cursor=cur_2"); - expect(second).not.toContain("after="); + expect(second).toContain("after=cur_2"); + expect(second).not.toContain("cursor="); expect(second).toContain("limit=1"); }); @@ -133,7 +133,7 @@ describe("topics resource (/api/v1)", () => { fetchMock.mockResolvedValue(topicPage([topic("top_1")], "cur_stuck")); const seen: string[] = []; - for await (const item of client.topics.listAll({ cursor: "cur_stuck" })) seen.push(item.id); + for await (const item of client.topics.listAll({ after: "cur_stuck" })) seen.push(item.id); expect(seen).toEqual(["top_1"]); expect(fetchMock.mock.calls).toHaveLength(1); diff --git a/src/__tests__/validation.test.ts b/src/__tests__/validation.test.ts index cf28152..4164353 100644 --- a/src/__tests__/validation.test.ts +++ b/src/__tests__/validation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { SendlyValidationError } from "../index"; import type { EmailValidationResultListV1 } from "../types"; -import { getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers"; +import { cursorPage, getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers"; type ValidationResult = EmailValidationResultListV1["data"][number]; @@ -21,13 +21,12 @@ function validation(email: string, verdict: ValidationResult["verdict"]): Valida /** * One page of a run's results. * - * Deliberately not `helpers.cursorPage`: this endpoint's envelope names the - * next page `cursor`, not `next_cursor`, so the shared builder would describe a - * shape the API never sends. + * `helpers.cursorPage`, the builder every other v1 list test uses. Through 1.0 + * this file had its own because the endpoint answered the next page under + * `cursor` rather than `next_cursor` — and a local fixture is exactly how a + * second dialect stays invisible, so it is gone rather than updated. */ -function resultsPage(data: ValidationResult[], cursor: string | null): Response { - return jsonResponse(200, { data, cursor, has_more: cursor !== null }); -} +const resultsPage = cursorPage; describe("validation resource (/api/v1)", () => { test("validateEmails POSTs the batch to /api/v1/email-validations", async () => { @@ -87,18 +86,18 @@ describe("validation resource (/api/v1)", () => { expect(run.status).toBe("running"); }); - test("listResults serializes limit, verdict and the `cursor` page parameter", async () => { + test("listResults serializes limit, verdict and the `after` page parameter", async () => { const { client, fetchMock } = makeClient(); fetchMock.mockResolvedValue(resultsPage([], null)); - await client.validation.listResults("vrun_1", { limit: 50, verdict: "undeliverable", cursor: "cur_1" }); + await client.validation.listResults("vrun_1", { limit: 50, verdict: "undeliverable", after: "cur_1" }); const { url } = getCall(fetchMock); expect(url).toContain("http://localhost/api/v1/validation-runs/vrun_1/results?"); expect(url).toContain("limit=50"); expect(url).toContain("verdict=undeliverable"); - expect(url).toContain("cursor=cur_1"); - expect(url).not.toContain("after="); + expect(url).toContain("after=cur_1"); + expect(url).not.toContain("cursor="); }); test("listResults resolves the envelope itself — the page is not unwrapped to its data array", async () => { @@ -108,11 +107,11 @@ describe("validation resource (/api/v1)", () => { const page = await client.validation.listResults("vrun_1"); expect(page.has_more).toBe(true); - expect(page.cursor).toBe("cur_2"); + expect(page.next_cursor).toBe("cur_2"); expect(page.data).toHaveLength(1); }); - test("listResultsAll pages on `cursor`, not the `after` the other v1 lists take", async () => { + test("listResultsAll pages on `after`, the one v1 pagination parameter", async () => { const { client, fetchMock } = makeClient(); fetchMock .mockResolvedValueOnce(resultsPage([validation("a@example.com", "undeliverable")], "cur_2")) @@ -126,8 +125,8 @@ describe("validation resource (/api/v1)", () => { expect(seen).toEqual(["a@example.com", "b@example.com"]); expect(fetchMock.mock.calls).toHaveLength(2); const second = getCall(fetchMock, 1).url; - expect(second).toContain("cursor=cur_2"); - expect(second).not.toContain("after="); + expect(second).toContain("after=cur_2"); + expect(second).not.toContain("cursor="); expect(second).toContain("verdict=undeliverable"); }); diff --git a/src/resources/topics.ts b/src/resources/topics.ts index e8fa933..3bad603 100644 --- a/src/resources/topics.ts +++ b/src/resources/topics.ts @@ -1,4 +1,5 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import type { CreateTopicV1Request, ListTopicsV1Query, @@ -27,7 +28,7 @@ export class TopicsResource { * is no delete — archiving is the retire button, because a topic is where * people's answers are recorded. {@link listAll} drives the loop for you. * - * Paginated on `limit` + `cursor`, not the `after` the rest of v1 uses. + * Paginated on `limit` + `after`, like every other v1 collection. */ async list(query?: ListTopicsV1Query): Promise { return this.client.request({ @@ -40,23 +41,15 @@ export class TopicsResource { /** * Iterate every topic across pages, yielding one topic at a time. * - * The walk is written out here rather than delegated to `paginateCursor` - * because this endpoint names its cursor `cursor` on both sides — the query - * parameter and the response field — where every other v1 list takes `after` - * and answers `next_cursor`. + * This used to be written out by hand: the endpoint named its cursor `cursor` + * on both sides where every other v1 list takes `after` and answers + * `next_cursor`, so the shared walker sent a parameter the route ignored and + * read a field it never returned — which silently re-fetched page one until + * `has_more` happened to be false. The route speaks the one dialect now, so + * this delegates like every other collection. */ async *listAll(query?: ListTopicsV1Query): AsyncGenerator { - let cursor = query?.cursor; - for (;;) { - const page = await this.list({ ...query, cursor }); - for (const topic of page.data) { - yield topic; - } - const next = page.cursor; - // A page that repeats the cursor it was handed would otherwise spin forever. - if (!page.has_more || next === null || next === cursor) return; - cursor = next; - } + yield* paginateCursor((after) => this.list({ ...query, after }), query?.after); } /** diff --git a/src/resources/validation.ts b/src/resources/validation.ts index 517986f..9d44dc4 100644 --- a/src/resources/validation.ts +++ b/src/resources/validation.ts @@ -1,4 +1,5 @@ import type { Sendly } from "../client"; +import { paginateCursor } from "../pagination"; import type { EmailValidationBatchV1, EmailValidationResultListV1, @@ -63,9 +64,8 @@ export class ValidationResource { * 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. * - * This list pages on `cursor`, not the `after` every other v1 collection - * takes, and its envelope carries the next page under `cursor` rather than - * `next_cursor`. {@link listResultsAll} drives that loop for you. + * Pages on `after` and answers `next_cursor`, like every other v1 + * collection. {@link listResultsAll} drives that loop for you. */ async listResults(id: string, query?: ListValidationResultsV1Query): Promise { return this.client.request({ @@ -78,25 +78,15 @@ export class ValidationResource { /** * Iterate every result across pages, yielding one address's verdict at a time. * - * Hand-rolled rather than routed through `paginateCursor`: the shared helper - * sends `after` and reads `next_cursor`, and this endpoint speaks `cursor` on - * both sides, so the helper would send an ignored parameter and re-fetch page - * one forever. Stops on `has_more: false`, a null cursor, or a cursor the - * server repeats. + * This was hand-rolled through 1.0, because the endpoint spoke `cursor` on + * both sides while the shared helper 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. */ async *listResultsAll( id: string, query?: ListValidationResultsV1Query, ): AsyncGenerator { - let cursor = query?.cursor; - for (;;) { - const page = await this.listResults(id, { ...query, cursor }); - for (const result of page.data ?? []) { - yield result; - } - const next = page.cursor; - if (!page.has_more || next === null || next === undefined || next === cursor) return; - cursor = next; - } + yield* paginateCursor((after) => this.listResults(id, { ...query, after }), query?.after); } } diff --git a/src/types.generated.ts b/src/types.generated.ts index 945f561..2a073b4 100644 --- a/src/types.generated.ts +++ b/src/types.generated.ts @@ -4,7 +4,7 @@ */ export interface paths { - "/api/v1/campaigns": { + "/api/contacts": { parameters: { query?: never; header?: never; @@ -12,69 +12,55 @@ export interface paths { cookie?: never; }; /** - * List campaigns - * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. - * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * List contacts + * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListCampaigns"]; + get: operations["listContacts"]; put?: never; /** - * Create a campaign - * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. - * - * The `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. - * - * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. + * Create a contact + * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateCampaign"]; + post: operations["createContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}": { + "/api/contacts/bulk": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve a campaign - * @description Fetch one campaign, including its materialized delivery counters. + * Bulk-create contacts + * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - get: operations["v1GetCampaign"]; - put?: never; - post?: never; + post: operations["bulkCreateContacts"]; /** - * Delete a campaign - * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. + * Bulk-delete contacts + * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteCampaign"]; + delete: operations["bulkDeleteContacts"]; options?: never; head?: never; - /** - * Update a campaign - * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. - * - * Changing `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. - * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. - */ - patch: operations["v1UpdateCampaign"]; + patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/send": { + "/api/contacts/upsert": { parameters: { query?: never; header?: never; @@ -84,69 +70,115 @@ export interface paths { get?: never; put?: never; /** - * Send or schedule a campaign - * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. - * - * **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. - * - * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * Create or update a contact by email + * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. * - * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1SendCampaign"]; + post: operations["upsertContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/cancel": { + "/api/contacts/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a contact + * @description Requires the `contacts:read` scope — View your contacts and their custom fields. + */ + get: operations["getContact"]; put?: never; + post?: never; /** - * Cancel a campaign - * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. + * Delete a contact + * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). * - * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + delete: operations["deleteContact"]; + options?: never; + head?: never; + /** + * Update a contact + * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CancelCampaign"]; + patch: operations["updateContact"]; + trace?: never; + }; + "/api/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List sending domains + * @description List all domains for the authenticated project. + * + * Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["listDomains"]; + put?: never; + /** + * Add a sending domain + * @description Register a new domain with SES and persist its DKIM tokens. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["addDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/pause": { + "/api/domains/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a sending domain + * @description Requires the `domains:read` scope — View your sending domains and their verification status. + */ + get: operations["getDomain"]; put?: never; + post?: never; /** - * Pause a sending campaign - * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. + * Remove a sending domain + * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1PauseCampaign"]; - delete?: never; + delete: operations["deleteDomain"]; options?: never; head?: never; - patch?: never; + /** + * Assign a sending identity to a stream + * @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. + * + * Streams 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. + * + * At 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + patch: operations["assignDomainStream"]; trace?: never; }; - "/api/v1/campaigns/{id}/resume": { + "/api/domains/{id}/dodomain-session": { parameters: { query?: never; header?: never; @@ -156,19 +188,23 @@ export interface paths { get?: never; put?: never; /** - * Resume a paused campaign - * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. + * Start guided DNS setup + * @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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * The 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. + * + * `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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["v1ResumeCampaign"]; + post: operations["startDomainSetup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/stats": { + "/api/domains/{id}/verify": { parameters: { query?: never; header?: never; @@ -176,23 +212,27 @@ export interface paths { cookie?: never; }; /** - * Retrieve campaign statistics - * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. - * - * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. + * Read SES verification status + * @description Read the current SES verification status without forcing a refresh. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - get: operations["v1GetCampaignStats"]; + get: operations["getDomainVerification"]; put?: never; - post?: never; + /** + * Trigger SES verification + * @description Force a refresh of the domain's SES verification status. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + */ + post: operations["verifyDomain"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/failures": { + "/api/emails": { parameters: { query?: never; header?: never; @@ -200,25 +240,27 @@ export interface paths { cookie?: never; }; /** - * List a campaign's failed sends - * @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. - * - * `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. - * - * Cursor-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. + * List emails + * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. * - * Requires the `campaigns:read` scope — View your campaigns and their performance. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["v1ListCampaignFailures"]; + get: operations["listEmails"]; put?: never; - post?: never; + /** + * Send a single transactional email + * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * + * Requires the `emails:send` scope — Send emails from your verified domains. + */ + post: operations["sendEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/campaigns/{id}/retry-failed": { + "/api/emails/batch": { parameters: { query?: never; header?: never; @@ -228,23 +270,21 @@ export interface paths { get?: never; put?: never; /** - * Retry a campaign's failed sends - * @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. - * - * The 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. + * Send a batch of emails + * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. * - * Only 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. + * The 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. * - * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + * Requires the `emails:send` scope — Send emails from your verified domains. */ - post: operations["v1RetryCampaignFailures"]; + post: operations["sendEmailBatch"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments": { + "/api/emails/{id}": { parameters: { query?: never; header?: never; @@ -252,91 +292,91 @@ export interface paths { cookie?: never; }; /** - * List segments - * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * Get a single email + * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. * - * `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. + * `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. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Requires the `emails:read` scope — View the emails you have sent and their delivery status. */ - get: operations["v1ListSegments"]; + get: operations["getEmail"]; put?: never; - /** - * Create a segment - * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. - * - * A `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. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. - */ - post: operations["v1CreateSegment"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/segments/{id}": { + "/api/emails/{id}/schedule": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Retrieve a segment - * @description Fetch one segment, including its saved `condition` and materialized `member_count`. - * - * Requires the `segments:read` scope — View your segments and who belongs to them. - */ - get: operations["v1GetSegment"]; + get?: never; put?: never; post?: never; /** - * Delete a segment - * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. - * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * Cancel a scheduled (still-PENDING) email + * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. */ - delete: operations["v1DeleteSegment"]; + delete: operations["cancelScheduledEmail"]; options?: never; head?: never; + patch?: never; + trace?: never; + }; + "/api/lists/{id}/subscribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; /** - * Update a segment - * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * Subscribe a contact to a list + * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. * - * `type` is not accepted here — see the create operation. + * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. * - * Requires the `segments:write` scope — Create, edit, and delete your segments. + * **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. + * + * `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. */ - patch: operations["v1UpdateSegment"]; + post: operations["subscribeToList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/v1/segments/{id}/contacts": { + "/api/lists/{id}/unsubscribe": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List the contacts in a segment - * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. - * - * Cursors 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. + * Unsubscribe a contact from a list + * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. * - * Requires the `segments:read` scope — View your segments and who belongs to them. + * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. */ - get: operations["v1ListSegmentContacts"]; - put?: never; - post?: never; + post: operations["unsubscribeFromList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows": { + "/api/mailboxes": { parameters: { query?: never; header?: never; @@ -344,31 +384,39 @@ export interface paths { cookie?: never; }; /** - * List workflows - * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. + * List mailboxes + * @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. * - * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * This 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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1ListWorkflows"]; + get: operations["listMailboxes"]; put?: never; /** - * Create a workflow - * @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`. + * Create a mailbox + * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. * - * Pass `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. + * Three consequences worth knowing before you call it: * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * - **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. + * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. + * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * + * Retrying 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. + * + * `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. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1CreateWorkflow"]; + post: operations["createMailbox"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}": { + "/api/mailboxes/{id}": { parameters: { query?: never; header?: never; @@ -376,37 +424,29 @@ export interface paths { cookie?: never; }; /** - * Retrieve a workflow - * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. + * Get a mailbox + * @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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1GetWorkflow"]; + get: operations["getMailbox"]; put?: never; post?: never; /** - * Delete a workflow - * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. + * Delete a mailbox + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires an admin of the project. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - delete: operations["v1DeleteWorkflow"]; + delete: operations["deleteMailbox"]; options?: never; head?: never; - /** - * Update a workflow - * @description Sparse update — omitted fields are left unchanged. - * - * Two 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. - * - * `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. - * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. - */ - patch: operations["v1UpdateWorkflow"]; + patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/executions": { + "/api/mailboxes/{id}/app-passwords": { parameters: { query?: never; header?: never; @@ -414,29 +454,33 @@ export interface paths { cookie?: never; }; /** - * List a workflow's executions - * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. + * List a mailbox's app passwords + * @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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1ListWorkflowExecutions"]; + get: operations["listAppPasswords"]; put?: never; /** - * Start a workflow for a contact - * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. + * Create an app password + * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. * - * 409 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. + * **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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * That 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. + * + * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1StartWorkflowExecution"]; + post: operations["createAppPassword"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/executions/{execution_id}/cancel": { + "/api/mailboxes/{id}/app-passwords/{passwordId}": { parameters: { query?: never; header?: never; @@ -445,106 +489,119 @@ export interface paths { }; get?: never; put?: never; + post?: never; /** - * Cancel a workflow execution - * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. + * Revoke an app password + * @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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires an admin of the project. An API key is refused with 401. + * + * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. */ - post: operations["v1CancelWorkflowExecution"]; - delete?: never; + delete: operations["revokeAppPassword"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/stats": { + "/api/mailboxes/{id}/drafts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve workflow statistics - * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. + * Draft a message with AI + * @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. * - * The 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. + * **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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. + * That 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. + * + * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * + * Drafting is capped at 120 requests per hour per project. + * + * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. */ - get: operations["v1GetWorkflowStats"]; - put?: never; - post?: never; + post: operations["draftMailboxMessage"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/graph": { + "/api/mailboxes/{id}/messages": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve a workflow's step graph - * @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. + * Send a message from a mailbox + * @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. * - * A 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. + * **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. * - * The 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. + * **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. * - * Requires the `workflows:read` scope — View your automation workflows and their runs. - */ - get: operations["v1GetWorkflowGraph"]; - /** - * Replace a workflow's step graph - * @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. + * Bcc 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. * - * A 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. + * Refusals worth handling by name: * - * Refused 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. + * - `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. + * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. + * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. + * - `429` — a mailbox may send 60 messages an hour through this endpoint. * - * The 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. + * The message is stored as a new conversation on the mailbox, so the reply threads onto it. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. */ - put: operations["v1ReplaceWorkflowGraph"]; - post?: never; + post: operations["sendMailboxMessage"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/clone": { + "/api/projects/{id}/api-keys": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List API keys for a project + * @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. + * + * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + */ + get: operations["listApiKeys"]; put?: never; /** - * Clone a workflow - * @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. + * Create an API key + * @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. * - * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. + * **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. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1CloneWorkflow"]; + post: operations["createApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/pause": { + "/api/projects/{id}/api-keys/{keyId}": { parameters: { query?: never; header?: never; @@ -553,24 +610,20 @@ export interface paths { }; get?: never; put?: never; + post?: never; /** - * Pause a workflow and cancel its running executions - * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. - * - * `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. - * - * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. + * Revoke an API key + * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1PauseWorkflow"]; - delete?: never; + delete: operations["revokeApiKey"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/workflows/{id}/resume": { + "/api/projects/{id}/api-keys/{keyId}/rotate": { parameters: { query?: never; header?: never; @@ -580,79 +633,77 @@ export interface paths { get?: never; put?: never; /** - * Resume a paused workflow - * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. - * - * Refused 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. + * Rotate an API key's secret + * @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`). * - * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. */ - post: operations["v1ResumeWorkflow"]; + post: operations["rotateApiKey"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails": { + "/api/snippets": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a transactional email - * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. - * - * This 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. - * - * Exactly 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. - * - * `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. + * List snippets + * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. * - * An 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. + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listSnippets"]; + put?: never; + /** + * Create a snippet + * @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. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1SendEmail"]; + post: operations["createSnippet"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/emails/test": { + "/api/snippets/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a snippet + * @description Requires the `templates:read` scope — View your email templates. + */ + get: operations["getSnippet"]; put?: never; + post?: never; /** - * Send a sandbox test email - * @description Prove that sending works — before any domain, DNS record or verification exists. - * - * The 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. - * - * That 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. - * - * Sandbox 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. + * Delete a snippet + * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. * - * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1SendTestEmail"]; - delete?: never; + delete: operations["deleteSnippet"]; options?: never; head?: never; - patch?: never; + /** + * Update a snippet + * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateSnippet"]; trace?: never; }; - "/api/emails": { + "/api/suppression": { parameters: { query?: never; header?: never; @@ -660,27 +711,27 @@ export interface paths { cookie?: never; }; /** - * List emails - * @description List emails for the authenticated project. Cursor-paginated for stable scroll over large result sets. + * List suppressed emails + * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `suppression:read` scope — View the addresses on your suppression list. */ - get: operations["listEmails"]; + get: operations["listSuppressions"]; put?: never; /** - * Send a single transactional email - * @description Send a single transactional email. Accepts a `template` ID or an inline `subject` + `body`. An optional `Idempotency-Key` request header (1–255 chars, 24h TTL) ensures replay safety: the first request wins and a retry carrying the same key AND the same body replays its response. Reusing a key with a DIFFERENT body answers `422 IDEMPOTENCY_KEY_REUSED` — a key names one request, so it is never silently served another request's result. + * Manually add an email to the suppression list + * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. */ - post: operations["sendEmail"]; + post: operations["addSuppression"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}": { + "/api/suppression/{email}": { parameters: { query?: never; header?: never; @@ -688,95 +739,109 @@ export interface paths { cookie?: never; }; /** - * Get a single email - * @description Fetch one email together with its DELIVERY history — the transitions behind `status`, oldest first. - * - * `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. + * Check whether an email is suppressed + * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. * - * Requires the `emails:read` scope — View the emails you have sent and their delivery status. + * Requires the `suppression:read` scope — View the addresses on your suppression list. */ - get: operations["getEmail"]; + get: operations["checkSuppression"]; put?: never; post?: never; - delete?: never; + /** + * Remove an email from the suppression list + * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * + * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + */ + delete: operations["removeSuppression"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/batch": { + "/api/templates": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a batch of emails - * @description Send up to 100 emails in one request. Returns 207 Multi-Status if any entry failed, or 200 if all succeeded. Per-entry results are reported in the `data` array. + * List templates + * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. * - * The 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. + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["listTemplates"]; + put?: never; + /** + * Create a template + * @description Create a new email template. The `from` domain must already be verified for the project. * - * Requires the `emails:send` scope — Send emails from your verified domains. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["sendEmailBatch"]; + post: operations["createTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/emails/{id}/schedule": { + "/api/templates/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * Get a template + * @description Requires the `templates:read` scope — View your email templates. + */ + get: operations["getTemplate"]; put?: never; post?: never; /** - * Cancel a scheduled (still-PENDING) email - * @description Mark a still-pending email as FAILED before the worker picks it up. Returns 409 if the email has already left PENDING. + * Delete a template + * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - delete: operations["cancelScheduledEmail"]; + delete: operations["deleteTemplate"]; options?: never; head?: never; - patch?: never; + /** + * Update a template + * @description Update one or more fields. If `from` changes, the new domain must already be verified. + * + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + patch: operations["updateTemplate"]; trace?: never; }; - "/api/contacts": { + "/api/track": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List contacts - * @description Cursor-paginated list of contacts. Supports filter by `search` and `subscribed`. - * - * Requires the `contacts:read` scope — View your contacts and their custom fields. - */ - get: operations["listContacts"]; + get?: never; put?: never; /** - * Create a contact - * @description Create a new contact. Returns 409 on `(projectId, email)` conflict — use `/api/contacts/upsert` for create-or-update semantics. + * Track a custom event for a contact + * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `events:write` scope — Record custom events for your contacts. */ - post: operations["createContact"]; + post: operations["trackEvent"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/upsert": { + "/api/users/me/projects": { parameters: { query?: never; header?: never; @@ -786,47 +851,47 @@ export interface paths { get?: never; put?: never; /** - * Create or update a contact by email - * @description Idempotent contact upsert keyed by email. Always answers 200 — the create-vs-update distinction is not signalled via status code. + * Create a project + * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Preconditions 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. + * + * Requires the `projects:write` scope — Create new projects on your account. */ - post: operations["upsertContact"]; + post: operations["createProject"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/bulk": { + "/api/v1/analytics/campaigns": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Bulk-create contacts - * @description Create up to 1000 contacts in one call. Per-row conflicts are reported as `skipped`. + * Retrieve campaign totals and engagement + * @description Campaign counts plus average open and click rates. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - post: operations["bulkCreateContacts"]; - /** - * Bulk-delete contacts - * @description Delete up to 1000 contacts in one call. Provide either `ids` or `emails`. + * `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. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - delete: operations["bulkDeleteContacts"]; + get: operations["v1GetCampaignAnalytics"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/contacts/{id}": { + "/api/v1/analytics/timeseries": { parameters: { query?: never; header?: never; @@ -834,31 +899,23 @@ export interface paths { cookie?: never; }; /** - * Get a contact - * @description Requires the `contacts:read` scope — View your contacts and their custom fields. + * Retrieve the daily email time series + * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. + * + * `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. + * + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - get: operations["getContact"]; + get: operations["v1GetAnalyticsTimeseries"]; put?: never; post?: never; - /** - * Delete a contact - * @description Hard-delete a contact. Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - delete: operations["deleteContact"]; + delete?: never; options?: never; head?: never; - /** - * Update a contact - * @description Update `data` and/or `subscribed`. `email` is immutable here — use `/api/contacts/upsert` to change addresses. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - patch: operations["updateContact"]; + patch?: never; trace?: never; }; - "/api/v1/contacts": { + "/api/v1/analytics/top-campaigns": { parameters: { query?: never; header?: never; @@ -866,29 +923,23 @@ export interface paths { cookie?: never; }; /** - * List contacts - * @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. + * List the best-performing campaigns + * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. * - * A 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. + * `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. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. */ - get: operations["v1ListContacts"]; + get: operations["v1ListTopCampaigns"]; put?: never; - /** - * Create a contact - * @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. - * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - post: operations["v1CreateContact"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/contacts/{id}": { + "/api/v1/campaigns": { parameters: { query?: never; header?: never; @@ -896,63 +947,69 @@ export interface paths { cookie?: never; }; /** - * Retrieve a contact - * @description Fetch one contact by id. + * List campaigns + * @description Cursor-paginated list of campaigns, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `contacts:read` scope — View your contacts and their custom fields. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. + * + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - get: operations["v1GetContact"]; + get: operations["v1ListCampaigns"]; put?: never; - post?: never; /** - * Delete a contact - * @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. + * Create a campaign + * @description Create a campaign in `DRAFT`. Creating never sends — `POST /api/v1/campaigns/{id}/send` is the only operation that puts mail on the wire — so a campaign can be built up and reviewed before it costs anything. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. - */ - delete: operations["v1DeleteContact"]; - options?: never; - head?: never; - /** - * Update a contact - * @description Partial update. Omitted fields are left alone. - * - * `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. + * The `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. * - * `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. + * `segment_id` is required when `audience_type` is `SEGMENT`; `audience_condition` is required when it is `FILTERED`. Both answer 422 when missing. * - * Requires the `contacts:write` scope — Create, update, and delete your contacts. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - patch: operations["v1UpdateContact"]; + post: operations["v1CreateCampaign"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/lists/{id}/subscribe": { + "/api/v1/campaigns/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Subscribe a contact to a list - * @description Add a contact to a list, creating the contact if it does not exist. When the list has `doubleOptIn` enabled the membership is created as `PENDING` and the response carries a `confirmToken` — Sendly does NOT send the confirmation email, so the caller must deliver `/api/lists/confirm-subscription?token=` to the contact itself. - * - * Accepts SENDING_ONLY (`pk_*`) keys so it can back a public subscribe form. + * Retrieve a campaign + * @description Fetch one campaign, including its materialized delivery counters. * - * **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. + * Requires the `campaigns:read` scope — View your campaigns and their performance. + */ + get: operations["v1GetCampaign"]; + put?: never; + post?: never; + /** + * Delete a campaign + * @description Delete a `DRAFT` campaign. A campaign that has sent is the record of what went out and cannot be deleted (400) — cancel it instead if it is still scheduled or in flight. * - * `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. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["subscribeToList"]; - delete?: never; + delete: operations["v1DeleteCampaign"]; options?: never; head?: never; - patch?: never; + /** + * Update a campaign + * @description Partial update: an omitted field is left untouched. Only `DRAFT` and `SCHEDULED` campaigns are editable — editing one that is already sending would change what half its recipients receive, so it answers 400. + * + * Changing `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. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. + */ + patch: operations["v1UpdateCampaign"]; trace?: never; }; - "/api/lists/{id}/unsubscribe": { + "/api/v1/campaigns/{id}/cancel": { parameters: { query?: never; header?: never; @@ -962,19 +1019,21 @@ export interface paths { get?: never; put?: never; /** - * Unsubscribe a contact from a list - * @description Mark the contact's membership on this list as `UNSUBSCRIBED`. Accepts SENDING_ONLY (`pk_*`) keys so it can back a public preference form. Idempotent — unsubscribing an address that is not a member succeeds. + * Cancel a campaign + * @description Cancel a `SCHEDULED`, `SENDING`, or `PAUSED` campaign. Terminal — a cancelled campaign cannot be resumed or re-sent. Takes no request body. * - * Once a membership is `UNSUBSCRIBED`, a later `POST /api/lists/{id}/subscribe` needs `allowResubscribe: true` to reverse it. + * Like every action on this resource, the response is the campaign itself, so `status` tells you what the transition did without a second request. + * + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["unsubscribeFromList"]; + post: operations["v1CancelCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/lists": { + "/api/v1/campaigns/{id}/failures": { parameters: { query?: never; header?: never; @@ -982,189 +1041,145 @@ export interface paths { cookie?: never; }; /** - * List subscriber lists - * @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. - * - * `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. - * - * Requires the `lists:read` scope — View your subscriber lists and who is on them. - */ - get: operations["v1ListLists"]; - put?: never; - /** - * Create a subscriber list - * @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`. + * List a campaign's failed sends + * @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. * - * **`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. + * `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. * - * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. + * Cursor-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. * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["v1CreateList"]; + get: operations["v1ListCampaignFailures"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/lists/{id}": { + "/api/v1/campaigns/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Retrieve a subscriber list - * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. - * - * Requires the `lists:read` scope — View your subscriber lists and who is on them. - */ - get: operations["v1GetList"]; + get?: never; put?: never; - post?: never; /** - * Delete a subscriber list - * @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. + * Pause a sending campaign + * @description Pause a `SENDING` campaign. The workers check the flag between batches, so a small number of already-queued emails may still be delivered after this returns. Takes no request body. * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - delete: operations["v1DeleteList"]; + post: operations["v1PauseCampaign"]; + delete?: never; options?: never; head?: never; - /** - * Update a subscriber list - * @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. - * - * **`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. - * - * Turning `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. - * - * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. - */ - patch: operations["v1UpdateList"]; + patch?: never; trace?: never; }; - "/api/domains": { + "/api/v1/campaigns/{id}/resume": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List sending domains - * @description List all domains for the authenticated project. - * - * Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["listDomains"]; + get?: never; put?: never; /** - * Add a sending domain - * @description Register a new domain with SES and persist its DKIM tokens. + * Resume a paused campaign + * @description Resume a `PAUSED` campaign from the last entry in its per-contact send ledger. That ledger also dedupes, so a contact already sent to is skipped rather than mailed twice. Takes no request body. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - post: operations["addDomain"]; + post: operations["v1ResumeCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}": { + "/api/v1/campaigns/{id}/retry-failed": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a sending domain - * @description Requires the `domains:read` scope — View your sending domains and their verification status. - */ - get: operations["getDomain"]; + get?: never; put?: never; - post?: never; - /** - * Remove a sending domain - * @description Removes the domain from the project. The underlying SES identity is also dropped if no other project still uses it. - * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - delete: operations["deleteDomain"]; - options?: never; - head?: never; /** - * Assign a sending identity to a stream - * @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. + * Retry a campaign's failed sends + * @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. * - * Streams 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. + * The 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. * - * At 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. + * Only 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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:write` scope — Create, edit, and organize your campaigns. */ - patch: operations["assignDomainStream"]; + post: operations["v1RetryCampaignFailures"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/domains/{id}/verify": { + "/api/v1/campaigns/{id}/send": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Read SES verification status - * @description Read the current SES verification status without forcing a refresh. - * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. - */ - get: operations["getDomainVerification"]; + get?: never; put?: never; /** - * Trigger SES verification - * @description Force a refresh of the domain's SES verification status. + * Send or schedule a campaign + * @description Start sending immediately, or park the campaign in `SCHEDULED` by passing `scheduled_for` (which must be in the future). The request body may be omitted entirely to send now. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * **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. + * + * Answers 400 when the campaign is not `DRAFT`/`SCHEDULED` or has no recipients, and 403 when the send would exceed the project's billing limit. + * + * Requires the `campaigns:send` scope — Send or schedule your campaigns to their audience. */ - post: operations["verifyDomain"]; + post: operations["v1SendCampaign"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/domains/{id}/dodomain-session": { + "/api/v1/campaigns/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Start guided DNS setup - * @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. - * - * The 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. + * Retrieve campaign statistics + * @description Delivery and engagement counters for one campaign, plus the rates derived from them. Every number is a materialized counter on the campaign row, so this is a single indexed read regardless of how many emails the campaign sent — cheap enough to poll while a campaign is in flight. * - * `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. + * Rates are percentages (0–100) against `sent`, and are 0 before anything has been sent. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `campaigns:read` scope — View your campaigns and their performance. */ - post: operations["startDomainSetup"]; + get: operations["v1GetCampaignStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/domains": { + "/api/v1/contacts": { parameters: { query?: never; header?: never; @@ -1172,35 +1187,29 @@ export interface paths { cookie?: never; }; /** - * List sending domains - * @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. + * List contacts + * @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. * - * `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. + * A 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. * - * Requires the `domains:read` scope — View your sending domains and their verification status. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1ListDomains"]; + get: operations["v1ListContacts"]; put?: never; /** - * Add a sending domain - * @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. - * - * `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. - * - * `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. - * - * A 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. + * Create a contact + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - post: operations["v1CreateDomain"]; + post: operations["v1CreateContact"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/domains/{id}": { + "/api/v1/contacts/{id}": { parameters: { query?: never; header?: never; @@ -1208,55 +1217,63 @@ export interface paths { cookie?: never; }; /** - * Retrieve a sending domain - * @description Fetch one sending domain by id. + * Retrieve a contact + * @description Fetch one contact by id. * - * Requires the `domains:read` scope — View your sending domains and their verification status. + * Requires the `contacts:read` scope — View your contacts and their custom fields. */ - get: operations["v1GetDomain"]; + get: operations["v1GetContact"]; put?: never; post?: never; /** - * Remove a sending domain - * @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. - * - * The 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. + * Delete a contact + * @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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `contacts:write` scope — Create, update, and delete your contacts. */ - delete: operations["v1DeleteDomain"]; + delete: operations["v1DeleteContact"]; options?: never; head?: never; - patch?: never; + /** + * Update a contact + * @description Partial update. Omitted fields are left alone. + * + * `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. + * + * `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. + * + * Requires the `contacts:write` scope — Create, update, and delete your contacts. + */ + patch: operations["v1UpdateContact"]; trace?: never; }; - "/api/v1/domains/{id}/verify": { + "/api/v1/contacts/{id}/topics": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Refresh a sending domain's verification state - * @description Re-read this domain's state from SES and DNS and return the refreshed document. + * Get a contact's topic preferences + * @description Everything this contact has said they want, as the send path reads it. * - * This 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. + * `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. * - * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * The 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. * - * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - post: operations["v1VerifyDomain"]; + get: operations["v1GetContactTopicPreferences"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates": { + "/api/v1/deliverability/diagnose": { parameters: { query?: never; header?: never; @@ -1264,27 +1281,25 @@ export interface paths { cookie?: never; }; /** - * List templates - * @description Cursor-paginated list of templates. Use `search` for full-text-ish filtering on name/description/subject. + * Diagnose why mail from a domain is not arriving + * @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. * - * Requires the `templates:read` scope — View your email templates. - */ - get: operations["listTemplates"]; - put?: never; - /** - * Create a template - * @description Create a new email template. The `from` domain must already be verified for the project. + * Everything 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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * `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. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - post: operations["createTemplate"]; + get: operations["v1DiagnoseDeliverability"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/templates/{id}": { + "/api/v1/deliverability/dmarc": { parameters: { query?: never; header?: never; @@ -1292,31 +1307,27 @@ export interface paths { cookie?: never; }; /** - * Get a template - * @description Requires the `templates:read` scope — View your email templates. + * DMARC aggregate reports for your domains + * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * + * The 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. + * + * `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. + * + * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - get: operations["getTemplate"]; + get: operations["v1ListDmarcReports"]; put?: never; post?: never; - /** - * Delete a template - * @description Answers 200 with `{ success, data: { id } }` (pre-seam this was 204 No Content). Refuses with 409 if the template is still attached to a workflow step or active campaign. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - delete: operations["deleteTemplate"]; + delete?: never; options?: never; head?: never; - /** - * Update a template - * @description Update one or more fields. If `from` changes, the new domain must already be verified. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateTemplate"]; + patch?: never; trace?: never; }; - "/api/v1/templates": { + "/api/v1/deliverability/domains": { parameters: { query?: never; header?: never; @@ -1324,31 +1335,25 @@ export interface paths { cookie?: never; }; /** - * List templates - * @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. + * Delivery outcomes per recipient domain + * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. * - * `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. + * This 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. * - * A 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. + * The 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. * - * Requires the `templates:read` scope — View your email templates. + * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. */ - get: operations["v1ListTemplates"]; + get: operations["v1ListRecipientDomainStats"]; put?: never; - /** - * Create a template - * @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. - * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - post: operations["v1CreateTemplate"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/templates/{id}": { + "/api/v1/domains": { parameters: { query?: never; header?: never; @@ -1356,37 +1361,35 @@ export interface paths { cookie?: never; }; /** - * Retrieve a template - * @description Fetch one template by id. + * List sending domains + * @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. * - * Requires the `templates:read` scope — View your email templates. + * `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. + * + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["v1GetTemplate"]; + get: operations["v1ListDomains"]; put?: never; - post?: never; /** - * Delete a template - * @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. + * Add a sending domain + * @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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - delete: operations["v1DeleteTemplate"]; - options?: never; - head?: never; - /** - * Update a template - * @description Partial update. Omitted fields are left alone. + * `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. * - * Changing `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. + * `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. * - * A `from` supplied here is verified before anything is written, on the same terms as create. + * A 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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - patch: operations["v1UpdateTemplate"]; + post: operations["v1CreateDomain"]; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/api/snippets": { + "/api/v1/domains/{id}": { parameters: { query?: never; header?: never; @@ -1394,115 +1397,113 @@ export interface paths { cookie?: never; }; /** - * List snippets - * @description Cursor-paginated list of the project's reusable template fragments. `search` matches name and description. + * Retrieve a sending domain + * @description Fetch one sending domain by id. * - * Requires the `templates:read` scope — View your email templates. + * Requires the `domains:read` scope — View your sending domains and their verification status. */ - get: operations["listSnippets"]; + get: operations["v1GetDomain"]; put?: never; + post?: never; /** - * Create a snippet - * @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. + * Remove a sending domain + * @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. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * The 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. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - post: operations["createSnippet"]; - delete?: never; + delete: operations["v1DeleteDomain"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/snippets/{id}": { + "/api/v1/domains/{id}/verify": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a snippet - * @description Requires the `templates:read` scope — View your email templates. - */ - get: operations["getSnippet"]; + get?: never; put?: never; - post?: never; /** - * Delete a snippet - * @description Templates that still include the snippet keep rendering — an absent snippet renders as an empty string, exactly like an absent variable. + * Refresh a sending domain's verification state + * @description Re-read this domain's state from SES and DNS and return the refreshed document. * - * Requires the `templates:write` scope — Create, edit, and delete your email templates. + * This 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. + * + * A POST rather than a GET because it writes: the refreshed state is persisted, and a verified/unverified transition notifies the project. + * + * Requires the `domains:write` scope — Add and remove sending domains, and trigger verification. */ - delete: operations["deleteSnippet"]; + post: operations["v1VerifyDomain"]; + delete?: never; options?: never; head?: never; - /** - * Update a snippet - * @description Requires the `templates:write` scope — Create, edit, and delete your email templates. - */ - patch: operations["updateSnippet"]; + patch?: never; trace?: never; }; - "/api/webhooks": { + "/api/v1/email-validations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List user webhooks - * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). - * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. - */ - get: operations["listWebhooks"]; + get?: never; put?: never; /** - * Create a webhook - * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. + * Validate a batch of email addresses + * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * The 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. + * + * `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. + * + * The 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. */ - post: operations["createWebhook"]; + post: operations["v1ValidateEmails"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}": { + "/api/v1/emails": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get a webhook - * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. - */ - get: operations["getWebhook"]; + get?: never; put?: never; - post?: never; /** - * Delete a webhook - * @description Hard-delete a webhook. Cascades to all WebhookCall rows. + * Send a transactional email + * @description Send one transactional email and receive its delivery status in the same response. Accepts a `template` id or an inline `subject` + `body`. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * This 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. + * + * Exactly 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. + * + * `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. + * + * An 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. + * + * Requires the `emails:send` scope — Send emails from your verified domains. */ - delete: operations["deleteWebhook"]; + post: operations["v1SendEmail"]; + delete?: never; options?: never; head?: never; - /** - * Update a webhook - * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - patch: operations["updateWebhook"]; + patch?: never; trace?: never; }; - "/api/webhooks/{id}/rotate-secret": { + "/api/v1/emails/test": { parameters: { query?: never; header?: never; @@ -1512,19 +1513,25 @@ export interface paths { get?: never; put?: never; /** - * Rotate the webhook signing secret - * @description Generate a new shared secret. Returns the new plaintext secret exactly once. + * Send a sandbox test email + * @description Prove that sending works — before any domain, DNS record or verification exists. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * The 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. + * + * That 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. + * + * Sandbox 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. + * + * Requires the `emails:test` scope — Send test emails to your own address from the Sendly sandbox. */ - post: operations["rotateWebhookSecret"]; + post: operations["v1SendTestEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/webhooks/{id}/calls": { + "/api/v1/events": { parameters: { query?: never; header?: never; @@ -1532,21 +1539,37 @@ export interface paths { cookie?: never; }; /** - * List recent webhook calls - * @description Cursor-paginated list of recent delivery attempts for a single webhook. + * List events + * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * A 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["listWebhookCalls"]; + get: operations["v1ListEvents"]; put?: never; - post?: never; + /** + * Record an event + * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. + * + * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * + * Reserved 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. + * + * This 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. + * + * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * + * Requires the `events:write` scope — Record custom events for your contacts. + */ + post: operations["v1TrackEvent"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/webhooks": { + "/api/v1/events/names": { parameters: { query?: never; header?: never; @@ -1554,31 +1577,21 @@ export interface paths { cookie?: never; }; /** - * List webhooks - * @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. - * - * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. + * List event names + * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["v1ListWebhooks"]; + get: operations["v1ListEventNames"]; put?: never; - /** - * Create a webhook - * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. - * - * The 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. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - post: operations["v1CreateWebhook"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/webhooks/{id}": { + "/api/v1/events/stats": { parameters: { query?: never; header?: never; @@ -1586,63 +1599,57 @@ export interface paths { cookie?: never; }; /** - * Retrieve a webhook - * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. + * Retrieve event counts + * @description Per-name event counts over a bounded window, most frequent first. * - * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + * The 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. + * + * Requires the `events:read` scope — View the custom events your application has recorded. */ - get: operations["v1GetWebhook"]; + get: operations["v1GetEventStats"]; put?: never; post?: never; - /** - * Delete a webhook - * @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. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - delete: operations["v1DeleteWebhook"]; + delete?: never; options?: never; head?: never; - /** - * Update a webhook - * @description Partial update. 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 part of this response. - * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. - */ - patch: operations["v1UpdateWebhook"]; + patch?: never; trace?: never; }; - "/api/v1/webhooks/{id}/rotate-secret": { + "/api/v1/lists": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List subscriber lists + * @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. + * + * `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. + * + * Requires the `lists:read` scope — View your subscriber lists and who is on them. + */ + get: operations["v1ListLists"]; put?: never; /** - * Rotate a webhook signing secret - * @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. + * Create a subscriber list + * @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`. * - * Rotation 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. + * **`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. * - * `url`, `event_types` and `status` are unchanged. + * `description`, `confirmation_template_id` and `redirect_url` accept `null`, which means the same as omitting them: the field is left unset. * - * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["v1RotateWebhookSecret"]; + post: operations["v1CreateList"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys": { + "/api/v1/lists/{id}": { parameters: { query?: never; header?: never; @@ -1650,29 +1657,37 @@ export interface paths { cookie?: never; }; /** - * List API keys for a project - * @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. + * Retrieve a subscriber list + * @description Fetch one list by id, with the same status-agnostic `member_count` the collection returns. * - * Requires the `api-keys:read` scope — See which API keys exist, including what each one is allowed to do. + * Requires the `lists:read` scope — View your subscriber lists and who is on them. */ - get: operations["listApiKeys"]; + get: operations["v1GetList"]; put?: never; + post?: never; /** - * Create an API key - * @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. - * - * **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. + * Delete a subscriber list + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. */ - post: operations["createApiKey"]; - delete?: never; + delete: operations["v1DeleteList"]; options?: never; head?: never; - patch?: never; + /** + * Update a subscriber list + * @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. + * + * **`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. + * + * Turning `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. + * + * Requires the `lists:write` scope — Create, rename, and delete your subscriber lists. + */ + patch: operations["v1UpdateList"]; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}/rotate": { + "/api/v1/lists/{id}/validation-runs": { parameters: { query?: never; header?: never; @@ -1682,41 +1697,49 @@ export interface paths { get?: never; put?: never; /** - * Rotate an API key's secret - * @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`). + * Validate every address on a list + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * This 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`. + * + * A 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. + * + * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. */ - post: operations["rotateApiKey"]; + post: operations["v1StartListValidationRun"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/projects/{id}/api-keys/{keyId}": { + "/api/v1/projects": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Revoke an API key - * @description Revoke an API key. Answers `{ success: true }` with no `data` key. 404 if the key does not exist under this project. + * Retrieve the authenticated project + * @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. * - * Requires the `api-keys:write` scope — Create, rotate, and revoke API keys — these keep working even after you disconnect this app. + * `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). + * + * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * + * Requires the `projects:read` scope — View your projects and their settings. */ - delete: operations["revokeApiKey"]; + get: operations["v1GetProject"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression": { + "/api/v1/segments": { parameters: { query?: never; header?: never; @@ -1724,27 +1747,31 @@ export interface paths { cookie?: never; }; /** - * List suppressed emails - * @description Cursor-paginated list of suppressed addresses. Filter by `reason`. + * List segments + * @description Cursor-paginated list of segments, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * `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. + * + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["listSuppressions"]; + get: operations["v1ListSegments"]; put?: never; /** - * Manually add an email to the suppression list - * @description The `source` field is auto-derived: `API` for API-key callers, `DASHBOARD` for session callers. + * Create a segment + * @description Create a `DYNAMIC` segment (a saved `condition`, re-evaluated against contacts on every read) or a `STATIC` one (an explicitly managed membership list). `type` is fixed at creation — it decides how membership is computed, so it cannot be changed later. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * A `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. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - post: operations["addSuppression"]; + post: operations["v1CreateSegment"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/suppression/{email}": { + "/api/v1/segments/{id}": { parameters: { query?: never; header?: never; @@ -1752,27 +1779,35 @@ export interface paths { cookie?: never; }; /** - * Check whether an email is suppressed - * @description Returns `{ suppressed, reason?, source?, createdAt? }`. The path parameter must be URL-encoded. + * Retrieve a segment + * @description Fetch one segment, including its saved `condition` and materialized `member_count`. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["checkSuppression"]; + get: operations["v1GetSegment"]; put?: never; post?: never; /** - * Remove an email from the suppression list - * @description Idempotent. Silently no-ops if the suppression doesn't exist. + * Delete a segment + * @description Delete a segment. Refused with 409 while any `DRAFT`, `SCHEDULED`, or `SENDING` campaign still targets it — deleting it would leave those campaigns pointing at an audience that no longer exists, and the failure would surface at send time instead of here. Remove the segment from those campaigns first. * - * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. + * Requires the `segments:write` scope — Create, edit, and delete your segments. */ - delete: operations["removeSuppression"]; + delete: operations["v1DeleteSegment"]; options?: never; head?: never; - patch?: never; + /** + * Update a segment + * @description Partial update: an omitted field is left untouched. Changing a `DYNAMIC` segment's `condition` recomputes `member_count` in the same call, so the returned object never states a size that belongs to the previous filter. `condition` is ignored on a `STATIC` segment, whose membership is the explicit list. + * + * `type` is not accepted here — see the create operation. + * + * Requires the `segments:write` scope — Create, edit, and delete your segments. + */ + patch: operations["v1UpdateSegment"]; trace?: never; }; - "/api/v1/suppressions": { + "/api/v1/segments/{id}/contacts": { parameters: { query?: never; header?: never; @@ -1780,22 +1815,46 @@ export interface paths { cookie?: never; }; /** - * List suppressed addresses - * @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. + * List the contacts in a segment + * @description Cursor-paginated members of a segment. For a `STATIC` segment these are the rows of its membership list; for a `DYNAMIC` one the saved `condition` is evaluated against contacts as the page is read, so the result always reflects the contacts as they are now. * - * A 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. + * Cursors 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. * - * Requires the `suppression:read` scope — View the addresses on your suppression list. + * Requires the `segments:read` scope — View your segments and who belongs to them. */ - get: operations["v1ListSuppressions"]; + get: operations["v1ListSegmentContacts"]; put?: never; - /** - * Suppress an address - * @description Add an address to this project's suppression list, so no further send reaches it. - * - * Idempotent: 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. - * - * `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. + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suppressions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List suppressed addresses + * @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. + * + * A 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. + * + * Requires the `suppression:read` scope — View the addresses on your suppression list. + */ + get: operations["v1ListSuppressions"]; + put?: never; + /** + * Suppress an address + * @description Add an address to this project's suppression list, so no further send reaches it. + * + * Idempotent: 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. + * + * `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. * * Requires the `suppression:write` scope — Add and remove addresses on your suppression list. */ @@ -1842,29 +1901,39 @@ export interface paths { patch?: never; trace?: never; }; - "/api/track": { + "/api/v1/templates": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * List templates + * @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. + * + * `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. + * + * A 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. + * + * Requires the `templates:read` scope — View your email templates. + */ + get: operations["v1ListTemplates"]; put?: never; /** - * Track a custom event for a contact - * @description Record a custom event, creating or updating the contact by email as a side effect. Requires a FULL (`sk_*`) key — SENDING_ONLY (`pk_*`) keys answer 403, since recording events is not sending mail. Reserved system event names (`email.*`, `contact.subscribed`/`unsubscribed`, `segment.*.entry`/`.exit`) are rejected. + * Create a template + * @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. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["trackEvent"]; + post: operations["v1CreateTemplate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/events": { + "/api/v1/templates/{id}": { parameters: { query?: never; header?: never; @@ -1872,37 +1941,37 @@ export interface paths { cookie?: never; }; /** - * List events - * @description Cursor-paginated list of recorded events, newest first. Filter by `event_name` to follow a single series. - * - * A 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. + * Retrieve a template + * @description Fetch one template by id. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `templates:read` scope — View your email templates. */ - get: operations["v1ListEvents"]; + get: operations["v1GetTemplate"]; put?: never; + post?: never; /** - * Record an event - * @description Records a custom event, optionally attached to a contact. Events drive segment membership and workflow triggers, so a matching enabled workflow starts as a result of this call. - * - * `contact_id` must already exist in this project — unlike `POST /api/track`, this endpoint never creates contacts. Omit it for a project-level event. + * Delete a template + * @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. * - * Reserved 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. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. + */ + delete: operations["v1DeleteTemplate"]; + options?: never; + head?: never; + /** + * Update a template + * @description Partial update. Omitted fields are left alone. * - * This 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. + * Changing `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. * - * Sending-only (`pk_*`) keys cannot record events — they hold the send capability and nothing else. + * A `from` supplied here is verified before anything is written, on the same terms as create. * - * Requires the `events:write` scope — Record custom events for your contacts. + * Requires the `templates:write` scope — Create, edit, and delete your email templates. */ - post: operations["v1TrackEvent"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["v1UpdateTemplate"]; trace?: never; }; - "/api/v1/events/names": { + "/api/v1/topics": { parameters: { query?: never; header?: never; @@ -1910,45 +1979,33 @@ export interface paths { cookie?: never; }; /** - * List event names - * @description Every distinct event name in the project, most frequent first — the vocabulary a caller needs before filtering events or pointing a workflow trigger at one. Unpaginated: the set is bounded by what the integration emits, not by event volume. + * List topics + * @description The subjects this project mails about, cursor-paginated and newest first. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Archived 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. + * + * `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. + * + * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - get: operations["v1ListEventNames"]; + get: operations["v1ListTopics"]; put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/events/stats": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; /** - * Retrieve event counts - * @description Per-name event counts over a bounded window, most frequent first. + * Create a topic + * @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. * - * The 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. + * `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`. * - * Requires the `events:read` scope — View the custom events your application has recorded. + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. */ - get: operations["v1GetEventStats"]; - put?: never; - post?: never; + post: operations["v1CreateTopic"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/timeseries": { + "/api/v1/topics/{id}": { parameters: { query?: never; header?: never; @@ -1956,49 +2013,53 @@ export interface paths { cookie?: never; }; /** - * Retrieve the daily email time series - * @description Daily counts of emails created, delivered, opened, clicked and bounced. Every day in the window is present even with zero activity, so the series never needs gap-filling. - * - * `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. - * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Retrieve a topic + * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. */ - get: operations["v1GetAnalyticsTimeseries"]; + get: operations["v1GetTopic"]; put?: never; post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** + * Update a topic + * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. + * + * `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. + * + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + */ + patch: operations["v1UpdateTopic"]; trace?: never; }; - "/api/v1/analytics/campaigns": { + "/api/v1/topics/{id}/subscriptions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve campaign totals and engagement - * @description Campaign counts plus average open and click rates. + * Subscribe or unsubscribe a contact from a topic + * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. * - * `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. + * `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. * - * `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. + * `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. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. */ - get: operations["v1GetCampaignAnalytics"]; - put?: never; - post?: never; + post: operations["v1SetTopicSubscription"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/analytics/top-campaigns": { + "/api/v1/usage": { parameters: { query?: never; header?: never; @@ -2006,14 +2067,19 @@ export interface paths { cookie?: never; }; /** - * List the best-performing campaigns - * @description Campaigns sent in the window, ranked by open rate, capped at 50 rows. Not cursor-paginated: a leaderboard is a top-N by definition, and paging one would mean re-ranking on every page. + * Retrieve current usage and limits + * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. * - * `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. + * Every 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. * - * Requires the `analytics:read` scope — View your sending analytics and engagement metrics. + * Two caveats worth reading before you alert on these numbers: + * + * - 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. + * - `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`. + * + * Requires the `usage:read` scope — View your usage totals and billing limits. */ - get: operations["v1ListTopCampaigns"]; + get: operations["v1GetUsage"]; put?: never; post?: never; delete?: never; @@ -2022,7 +2088,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/diagnose": { + "/api/v1/validation-runs/{id}": { parameters: { query?: never; header?: never; @@ -2030,16 +2096,14 @@ export interface paths { cookie?: never; }; /** - * Diagnose why mail from a domain is not arriving - * @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. - * - * Everything 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. + * Retrieve a validation run + * @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. * - * `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. + * There 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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `validation:read` scope — View your email validation runs and their results. */ - get: operations["v1DiagnoseDeliverability"]; + get: operations["v1GetValidationRun"]; put?: never; post?: never; delete?: never; @@ -2048,7 +2112,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/domains": { + "/api/v1/validation-runs/{id}/results": { parameters: { query?: never; header?: never; @@ -2056,16 +2120,16 @@ export interface paths { cookie?: never; }; /** - * Delivery outcomes per recipient domain - * @description Sent, delivered, bounced, complained and opened counts split by the RECIPIENT's domain and by UTC day, newest day first. + * List a validation run's results + * @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. * - * This 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. + * No 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. * - * The 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. + * `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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `validation:read` scope — View your email validation runs and their results. */ - get: operations["v1ListRecipientDomainStats"]; + get: operations["v1ListValidationRunResults"]; put?: never; post?: never; delete?: never; @@ -2074,7 +2138,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/deliverability/dmarc": { + "/api/v1/webhooks": { parameters: { query?: never; header?: never; @@ -2082,27 +2146,31 @@ export interface paths { cookie?: never; }; /** - * DMARC aggregate reports for your domains - * @description DMARC aggregate (RUA) reports receiving providers have sent about your verified domains, newest reporting window first. + * List webhooks + * @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. * - * The 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. + * Signing secrets are not on this response and cannot be read back — see `POST /api/v1/webhooks/{id}/rotate-secret` if you have lost one. * - * `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. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. + */ + get: operations["v1ListWebhooks"]; + put?: never; + /** + * Create a webhook + * @description Register an endpoint to receive HMAC-signed deliveries for the events named in `event_types`. * - * Only reports about a domain registered in this project are stored, so a report about a domain you have not added will not appear here. + * The 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. * - * Requires the `deliverability:read` scope — Check why mail from one of your domains is not arriving. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1ListDmarcReports"]; - put?: never; - post?: never; + post: operations["v1CreateWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/usage": { + "/api/v1/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -2110,54 +2178,63 @@ export interface paths { cookie?: never; }; /** - * Retrieve current usage and limits - * @description Email usage against the limits that are actually enforced: the current month's counts per source category, the monthly cap applied to their total, and today's sends against the trust-tier daily ceiling. - * - * Every 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. - * - * Two caveats worth reading before you alert on these numbers: - * - * - 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. - * - `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`. + * Retrieve a webhook + * @description Fetch one webhook endpoint by id. The signing secret is not part of this response. * - * Requires the `usage:read` scope — View your usage totals and billing limits. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetUsage"]; + get: operations["v1GetWebhook"]; put?: never; post?: never; - delete?: never; + /** + * Delete a webhook + * @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. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + delete: operations["v1DeleteWebhook"]; options?: never; head?: never; - patch?: never; + /** + * Update a webhook + * @description Partial update. 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 part of this response. + * + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. + */ + patch: operations["v1UpdateWebhook"]; trace?: never; }; - "/api/v1/projects": { + "/api/v1/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Retrieve the authenticated project - * @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. + * Rotate a webhook signing secret + * @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. * - * `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). + * Rotation 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. * - * To enumerate every project you belong to — a different question, and not project-scoped — use `GET /api/users/me/projects`. + * `url`, `event_types` and `status` are unchanged. * - * Requires the `projects:read` scope — View your projects and their settings. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1GetProject"]; - put?: never; - post?: never; + post: operations["v1RotateWebhookSecret"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes": { + "/api/v1/workflows": { parameters: { query?: never; header?: never; @@ -2165,39 +2242,53 @@ export interface paths { cookie?: never; }; /** - * List mailboxes - * @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. + * List workflows + * @description Cursor-paginated list of workflows, newest first. Pass the previous response's `next_cursor` as `after` to page forward; `has_more` is false and `next_cursor` is null on the last page. * - * This 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. + * Unlike the legacy `/api/*` endpoints, v1 responses are the bare payload (no `{ success, data }` envelope) and errors are RFC 9457 problem documents. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["listMailboxes"]; + get: operations["v1ListWorkflows"]; put?: never; /** - * Create a mailbox - * @description Provision a real receiving mailbox — `support@yourdomain.com` — on a domain you have already verified. - * - * Three consequences worth knowing before you call it: - * - * - **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. - * - **The domain must be verified.** An unverified domain answers 409; creating a mailbox is not a way to skip DNS. - * - **Ten per project.** The eleventh answers 409. Deleted mailboxes free their slot; failed ones never consumed one. + * Create a workflow + * @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`. * - * Retrying 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. + * Pass `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. * - * `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. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + post: operations["v1CreateWorkflow"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/executions/{execution_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel a workflow execution + * @description Stops one run and stamps it `CANCELLED`. The execution stays queryable — cancelling is a state change, not a delete. Addressed by execution id alone, so a caller holding one from a list does not need to carry the workflow id with it. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createMailbox"]; + post: operations["v1CancelWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}": { + "/api/v1/workflows/{id}": { parameters: { query?: never; header?: never; @@ -2205,29 +2296,37 @@ export interface paths { cookie?: never; }; /** - * Get a mailbox - * @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. + * Retrieve a workflow + * @description The workflow itself — its trigger, re-entry policy and rate cap. The step graph is not part of the v1 contract. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - get: operations["getMailbox"]; + get: operations["v1GetWorkflow"]; put?: never; post?: never; /** - * Delete a mailbox - * @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. - * - * Requires an admin of the project. + * Delete a workflow + * @description Refused with 409 while executions are still running: deleting a workflow cascades its executions away, and a contact mid-journey disappearing is data loss the caller cannot detect afterwards. Disable the workflow or cancel its runs first. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["deleteMailbox"]; + delete: operations["v1DeleteWorkflow"]; options?: never; head?: never; - patch?: never; + /** + * Update a workflow + * @description Sparse update — omitted fields are left unchanged. + * + * Two 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. + * + * `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. + * + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + patch: operations["v1UpdateWorkflow"]; trace?: never; }; - "/api/mailboxes/{id}/drafts": { + "/api/v1/workflows/{id}/clone": { parameters: { query?: never; header?: never; @@ -2237,98 +2336,115 @@ export interface paths { get?: never; put?: never; /** - * Draft a message with AI - * @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. + * Clone a workflow + * @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. * - * **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. + * Server-side rather than a read-then-write, so the copy is taken from one consistent read of the source. * - * That 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. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. + */ + post: operations["v1CloneWorkflow"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflows/{id}/executions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a workflow's executions + * @description One row per contact-run, newest first, cursor-paginated on the execution's start time. Filter by `status` to find stuck (`WAITING`) or failed runs. * - * Everything you pass — the brief, the draft, the recipient context — is treated strictly as data describing what to write, never as instructions to the model. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1ListWorkflowExecutions"]; + put?: never; + /** + * Start a workflow for a contact + * @description Enters one contact into an enabled workflow. Step processing runs asynchronously, so a 201 means the run was claimed — not that it finished. * - * Drafting is capped at 120 requests per hour per project. + * 409 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. * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["draftMailboxMessage"]; + post: operations["v1StartWorkflowExecution"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/messages": { + "/api/v1/workflows/{id}/graph": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Send a message from a mailbox - * @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. + * Retrieve a workflow's step graph + * @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. * - * **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. + * A 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. * - * **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. + * The 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. * - * Bcc 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. + * Requires the `workflows:read` scope — View your automation workflows and their runs. + */ + get: operations["v1GetWorkflowGraph"]; + /** + * Replace a workflow's step graph + * @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. * - * Refusals worth handling by name: + * A 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. * - * - `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. - * - `422 CONTENT_REFUSED` — the outbound content scanner refused the message. - * - `503 CONTENT_SCAN_UNAVAILABLE` — screening could not reach a verdict for a young project. Nothing was sent; retry shortly. - * - `429` — a mailbox may send 60 messages an hour through this endpoint. + * Refused 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. * - * The message is stored as a new conversation on the mailbox, so the reply threads onto it. + * The 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. * - * Requires the `mailboxes:send` scope — Write and send new email from your hosted mailboxes, as that address. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["sendMailboxMessage"]; + put: operations["v1ReplaceWorkflowGraph"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords": { + "/api/v1/workflows/{id}/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * List a mailbox's app passwords - * @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. - * - * Requires the `mailboxes:read` scope — View the mailboxes on your domains and their settings. - */ - get: operations["listAppPasswords"]; + get?: never; put?: never; /** - * Create an app password - * @description Mint an IMAP/SMTP credential for the mailbox, so a mail client can connect to it. - * - * **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. + * Pause a workflow and cancel its running executions + * @description Disables the workflow and cancels every `RUNNING`/`WAITING` execution inside it. * - * That 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. + * `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. * - * Requires an admin of the project. An API key is refused with 401 — this endpoint needs a user, so use an OAuth connection. + * Cancelling is terminal: `resume` re-opens the workflow to new runs, it does not put the cancelled contacts back where they were. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - post: operations["createAppPassword"]; + post: operations["v1PauseWorkflow"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/mailboxes/{id}/app-passwords/{passwordId}": { + "/api/v1/workflows/{id}/resume": { parameters: { query?: never; header?: never; @@ -2337,39 +2453,39 @@ export interface paths { }; get?: never; put?: never; - post?: never; /** - * Revoke an app password - * @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. + * Resume a paused workflow + * @description Re-enables the workflow so its trigger matches again. `cancelled_executions` is always 0 here — resuming starts nothing and stops nothing. * - * Requires an admin of the project. An API key is refused with 401. + * Refused 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. * - * Requires the `mailboxes:write` scope — Create and delete mailboxes on your verified domains. + * Requires the `workflows:write` scope — Create, edit, enable, and delete your automation workflows. */ - delete: operations["revokeAppPassword"]; + post: operations["v1ResumeWorkflow"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/users/me/projects": { + "/api/v1/workflows/{id}/stats": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create a project - * @description Create a new project owned by the authenticated user. Answers the raw project row directly — no `{ success, data }` envelope — with status 201. + * Retrieve workflow statistics + * @description Execution counts by status, average completion time, the emails this workflow sent (with opens and clicks), and per-goal conversion counts. All-time by default — pass `from` to narrow it. Unlike `/api/v1/analytics/*` there is no 90-day ceiling here, because every aggregate is already confined to this one workflow. * - * Preconditions 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. + * The 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. * - * Requires the `projects:write` scope — Create new projects on your account. + * Requires the `workflows:read` scope — View your automation workflows and their runs. */ - post: operations["createProject"]; + get: operations["v1GetWorkflowStats"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; @@ -2396,7 +2512,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/topics": { + "/api/webhooks": { parameters: { query?: never; header?: never; @@ -2404,87 +2520,27 @@ export interface paths { cookie?: never; }; /** - * List topics - * @description The subjects this project mails about, cursor-paginated and newest first. - * - * Archived 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. - * - * `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. - * - * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. - */ - get: operations["v1ListTopics"]; - put?: never; - /** - * Create a topic - * @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. - * - * `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`. - * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. - */ - post: operations["v1CreateTopic"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/topics/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Retrieve a topic - * @description Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. - */ - get: operations["v1GetTopic"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** - * Update a topic - * @description Rename it, re-describe it, flip `default_opt_in`, or archive it. - * - * `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. + * List user webhooks + * @description List all user-managed outbound webhooks for the auth'd project (secrets are not returned). * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - patch: operations["v1UpdateTopic"]; - trace?: never; - }; - "/api/v1/topics/{id}/subscriptions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; + get: operations["listWebhooks"]; put?: never; /** - * Subscribe or unsubscribe a contact from a topic - * @description The two directions behave differently, and the asymmetry is deliberate: consent needs proof, withdrawal of consent does not. - * - * `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. - * - * `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. + * Create a webhook + * @description Register a new outbound webhook. The plaintext signing secret is returned exactly once — store it securely. * - * Requires the `topics:write` scope — Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1SetTopicSubscription"]; + post: operations["createWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/contacts/{id}/topics": { + "/api/webhooks/{id}": { parameters: { query?: never; header?: never; @@ -2492,79 +2548,29 @@ export interface paths { cookie?: never; }; /** - * Get a contact's topic preferences - * @description Everything this contact has said they want, as the send path reads it. - * - * `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. - * - * The 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. - * - * Requires the `topics:read` scope — View the topics you mail about and who is subscribed to each. + * Get a webhook + * @description Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetContactTopicPreferences"]; + get: operations["getWebhook"]; put?: never; post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/email-validations": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Validate a batch of email addresses - * @description Check up to 50 addresses for whether they can receive mail, and for the signals that make one worth mailing. Billed per address. - * - * The 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. - * - * `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. - * - * The 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. + * Delete a webhook + * @description Hard-delete a webhook. Cascades to all WebhookCall rows. * - * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1ValidateEmails"]; - delete?: never; + delete: operations["deleteWebhook"]; options?: never; head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/lists/{id}/validation-runs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Validate every address on a list - * @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. - * - * This 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`. - * - * A 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. - * - * Requires the `validation:write` scope — Check whether email addresses can receive mail — this is billed per address. + * Update a webhook + * @description Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - post: operations["v1StartListValidationRun"]; - delete?: never; - options?: never; - head?: never; - patch?: never; + patch: operations["updateWebhook"]; trace?: never; }; - "/api/v1/validation-runs/{id}": { + "/api/webhooks/{id}/calls": { parameters: { query?: never; header?: never; @@ -2572,14 +2578,12 @@ export interface paths { cookie?: never; }; /** - * Retrieve a validation run - * @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. - * - * There 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. + * List recent webhook calls + * @description Cursor-paginated list of recent delivery attempts for a single webhook. * - * Requires the `validation:read` scope — View your email validation runs and their results. + * Requires the `webhooks:read` scope — View your webhook endpoints and their delivery history. */ - get: operations["v1GetValidationRun"]; + get: operations["listWebhookCalls"]; put?: never; post?: never; delete?: never; @@ -2588,26 +2592,22 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/validation-runs/{id}/results": { + "/api/webhooks/{id}/rotate-secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * List a validation run's results - * @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. - * - * No 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. - * - * `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. + * Rotate the webhook signing secret + * @description Generate a new shared secret. Returns the new plaintext secret exactly once. * - * Requires the `validation:read` scope — View your email validation runs and their results. + * Requires the `webhooks:write` scope — Create, edit, and delete your webhook endpoints. */ - get: operations["v1ListValidationRunResults"]; - put?: never; - post?: never; + post: operations["rotateWebhookSecret"]; delete?: never; options?: never; head?: never; @@ -2618,2616 +2618,2633 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { - /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ - Error: { - /** @enum {boolean} */ - success?: false; - error: { - message: string; - code: string; - details?: { - errors: unknown[]; - }; - }; - }; - /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ - Problem: { + /** @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. */ + AddDomainBody: { + domain: string; + /** Format: uuid */ + projectId?: string; /** - * Format: uri - * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. + * @enum {string} */ - type: string; - /** @description Short, stable summary — the same for every occurrence of a `type`. */ - title: string; - /** @description HTTP status code, repeated in the body. */ - status: number; - /** @description Explanation specific to this occurrence. */ - detail?: string; - /** @description Request path the failure occurred on. */ - instance?: string; - /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ - code: string; - /** @description Correlation id — quote it in support requests. */ - request_id?: string; - /** @description Field-level failures. Present on 422 `validation_error` responses. */ - errors?: { - /** @description RFC 6901 JSON Pointer to the offending field. */ - pointer: string; - code: string; - message: string; - }[]; - }; - /** @description Bare success envelope with no payload. */ - SuccessEmpty: { - /** @enum {boolean} */ - success: true; - }; - /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ - IdResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - id: string; - }; + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"]; + /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ + streamDefault?: boolean; }; - /** @description A subscriber/contact within a project. */ - Contact: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; + /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ + AddSuppression: { /** Format: email */ email: string; - subscribed: boolean; - customFields?: { - [key: string]: unknown; - } | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * @default MANUAL + * @enum {string} */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; - /** @description Cursor-paginated list of contacts. */ - ContactListResponse: { - /** @enum {boolean} */ - success: true; + /** @description Campaign counters and engagement over the window. */ + AnalyticsCampaignStatsV1: { + /** @description Campaigns in DRAFT or SCHEDULED. */ + active: number; + average_click_rate: number; + /** @description Percentage, one decimal place. */ + average_open_rate: number; + completed: number; + total: number; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ + AnalyticsTimeseriesV1: { data: { - data: components["schemas"]["Contact"][]; - total: number; - /** @description Cursor for the next page, or null on the last page. */ - nextCursor: string | null; - hasMore: boolean; - }; + bounces: number; + clicks: number; + /** Format: date-time */ + date: string; + delivered: number; + emails: number; + opens: number; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; - /** @description A reusable email template. */ - Template: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - description?: string | null; - subject: string; - body: string; - /** Format: email */ + /** @description Sent campaigns ranked by open rate. */ + AnalyticsTopCampaignsV1: { + data: { + click_rate: number; + clicked: number; + /** Format: uuid */ + id: string; + open_rate: number; + opened: number; + sent: number; + subject: string; + }[]; + window: components["schemas"]["AnalyticsWindowV1"]; + }; + /** @description The time range this response was computed over, after the 90-day clamp. */ + AnalyticsWindowV1: { + /** Format: date-time */ from: string; - fromName?: string | null; - /** Format: email */ - replyTo?: string | null; - /** @enum {string} */ - emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; + /** Format: date-time */ + to: string; + }; + /** @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. */ + ApiKey: { /** * Format: date-time * @description ISO 8601 datetime string */ createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; - }; - /** @description Cursor-paginated list of templates. */ - TemplateListResponse: { - /** @enum {boolean} */ - success: true; - data: { - data: components["schemas"]["Template"][]; - total: number; - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - hasMore: boolean; - }; - }; - /** @description A reusable fragment of template markup. */ - Snippet: { /** Format: uuid */ - id: string; + domainId: string | null; /** Format: uuid */ - projectId: string; - /** @description The literal identifier a template includes with `{{> name}}`. */ - name: string; - description?: string | null; - /** @description Template markup. Values it interpolates are escaped like any other. */ - body: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; + id: string; + /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ + lastFour: string; /** * Format: date-time * @description ISO 8601 datetime string */ - updatedAt: string; - }; - /** @description Cursor-paginated list of snippets. */ - SnippetListResponse: { - /** @enum {boolean} */ - success: true; - data: { - data: components["schemas"]["Snippet"][]; - total: number; - /** @description Cursor for the next page; omitted on the last page. */ - cursor?: string; - hasMore: boolean; - }; - }; - /** - * @description Delivery lifecycle of the message. Engagement is reported separately. - * @enum {string} - */ - EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ - Domain: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - verified: boolean; - region?: string | null; - dkim?: { - type: string; - name: string; - value: string; - }[]; - /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + lastUsedAt: string | null; /** - * @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 {string|null} + * @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 {string} */ - stream?: "TRANSACTIONAL" | "MARKETING" | null; - /** @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). */ - streamDefault?: boolean; - /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ - defaultFromAddress?: string | null; + legacyGrantPreset: "FULL" | "SENDING_ONLY"; /** - * Format: date-time - * @description ISO 8601 datetime string + * @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 {string} */ - createdAt: string; + mode: "LIVE" | "TEST"; + name: string; + /** Format: uuid */ + projectId: string; /** * Format: date-time - * @description ISO 8601 datetime string + * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. */ - updatedAt: string; + revokedAt: string | null; + /** @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. */ + scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; }; - /** @description List of all domains for the auth'd project. */ - DomainListResponse: { + /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ + ApiKeyListResponse: { + data: components["schemas"]["ApiKey"][]; /** @enum {boolean} */ success: true; - data: components["schemas"]["Domain"][]; - }; - /** @description Outcome of a verification check against SES. */ - DomainVerificationStatus: { - verified: boolean; - mxRecords?: string[]; - dkim?: { - type: string; - name: string; - value: string; - }[]; - mailFromDomain?: string | null; - /** - * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. - * @enum {string|null} - */ - mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; }; - /** @description A sent (or queued) transactional email. */ - Email: { + /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ + AppPassword: { + /** Format: date-time */ + createdAt: string; /** Format: uuid */ id: string; - /** Format: uuid */ - projectId: string; - from: string; - to: string; - subject: string; - status: components["schemas"]["EmailDeliveryStatus"]; + /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ + lastFour: string; /** * Format: date-time - * @description First open, or null. + * @description Null until a mail client has authenticated with it at least once. */ - openedAt: string | null; + lastUsedAt: string | null; + /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ + name: string; + /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ + scopes: ("imap" | "smtp")[]; + }; + /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ + AppPasswordReveal: { + /** Format: uuid */ + id: string; /** * Format: date-time - * @description First click, or null. + * @description When the link stops working. Five minutes after creation; the password itself does not expire. */ - clickedAt: string | null; + revealExpiresAt: string; /** - * Format: date-time - * @description Spam complaint, or null. + * Format: uri + * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. */ - complainedAt: string | null; - /** @description Total opens recorded. */ - opens: number; - /** @description Total clicks recorded. */ - clicks: number; - tags: string[]; - error?: string | null; + revealUrl: string; + }; + /** @description Body for PATCH /api/domains/{id}. */ + AssignDomainStream: { /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: email + * @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. */ - createdAt: string; + defaultFromAddress?: string | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. + * @enum {string|null} */ - updatedAt: string; - }; - /** @description One transition in a message's delivery history. */ - EmailEvent: { - /** Format: uuid */ - id: string; - status: components["schemas"]["EmailDeliveryStatus"]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - /** @description A transactional email together with its delivery history. */ - EmailWithEvents: components["schemas"]["Email"] & { - /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ - events: components["schemas"]["EmailEvent"][]; - }; - /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ - SendEmailRecipientResult: { - contact: { - /** Format: uuid */ - id: string; - /** Format: email */ - email: string; - }; - /** Format: uuid */ - email: string; - }; - /** @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`. */ - SendEmailData: { - emails: components["schemas"]["SendEmailRecipientResult"][]; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - /** @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. */ - SendEmailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["SendEmailData"]; + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @description Make this the project's default identity for its stream, demoting whichever held it. */ + streamDefault?: boolean; }; /** @description Per-row result in a batch send response. */ BatchEntryResult: { - index: number; - /** @enum {string} */ - status: "ok" | "error"; data?: components["schemas"]["SendEmailData"]; error?: { - message: string; code: string; + message: string; }; - }; - /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ - BatchSendResponse: { - success: boolean; - data: components["schemas"]["BatchEntryResult"][]; + index: number; + /** @enum {string} */ + status: "ok" | "error"; }; /** @description Batch send wrapper. Up to 100 entries. */ BatchSendBody: { emails: components["schemas"]["SendEmail"][]; }; - /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ - SendEmail: { - to: string | { - name?: string; - /** Format: email */ - email: string; - } | (string | { - name?: string; - /** Format: email */ - email: string; - })[]; - subject?: string; - body?: string; - /** Format: uuid */ - template?: string; - subscribed?: boolean; - name?: string; - from?: string | { - name?: string; - /** Format: email */ - email: string; - }; - /** Format: email */ - reply?: string; - headers?: { - [key: string]: string; - }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; - }; - attachments?: { - filename: string; - content: string; - contentType: string; - contentId?: string; - /** - * @default attachment - * @enum {string} - */ - disposition: "attachment" | "inline"; - }[]; - tags?: string[]; - cc?: string[]; - bcc?: string[]; - }; - /** @description Bulk create up to 1000 contacts. */ - ContactBulkCreateBody: { - contacts: components["schemas"]["CreateContact"][]; - }; - /** @description Body for POST /api/contacts and /api/contacts/upsert. */ - CreateContact: { - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - customFields?: { - [key: string]: unknown; - }; - }; - /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ - ContactBulkDeleteBody: { - ids?: string[]; - emails?: string[]; - }; - /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ - UpdateContactBody: { - subscribed?: boolean; - customFields?: { - [key: string]: unknown; - }; + /** @description Multi-status response for `POST /api/emails/batch`. HTTP 207 if any entry failed, else 200. */ + BatchSendResponse: { + data: components["schemas"]["BatchEntryResult"][]; + success: boolean; }; - /** @description A single suppressed-email record. */ - Suppression: { + /** @description A campaign as exposed on the v1 API. */ + CampaignV1: { + /** @enum {string} */ + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + /** Format: date-time */ + created_at: string; /** Format: uuid */ id: string; /** Format: uuid */ - projectId: string; - /** Format: email */ - email: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + list_id: string | null; + name: string; + /** Format: date-time */ + scheduled_at: string | null; + /** Format: date-time */ + sent_at: string | null; + stats: { + bounced: number; + clicked: number; + delivered: number; + opened: number; + sent: number; + total_recipients: number; + }; /** @enum {string} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD"; + status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; + subject: string; + /** Format: uuid */ + topic_id: string | null; + }; + /** @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`. */ + CampaignV1Create: { + audience_condition?: components["schemas"]["FilterConditionV1"]; /** - * Format: date-time - * @description ISO 8601 datetime string + * @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 {string} */ - createdAt: string; - }; - /** @description Cursor-paginated list of suppressions. */ - SuppressionListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Suppression"][]; - nextCursor?: string | null; - cursor?: string | null; - hasMore?: boolean; - }; - /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ - SuppressionCheckResponse: { - suppressed: boolean; - /** @enum {string} */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - /** @enum {string} */ - source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + body: string; + description?: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * @default MARKETING + * @enum {string} */ - createdAt?: string; - }; - /** @description A user-managed outbound webhook. */ - Webhook: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** Format: uri */ - url: string; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - consecutiveFailures: number; + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: email + * @description Sender address. Its domain must be verified for this project. */ - disabledAt?: string | null; - lastFour?: string; + from: string; + from_name?: string | null; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. */ - createdAt: string; + list_id?: string; + name: string; + /** Format: email */ + reply_to?: string | null; + /** Format: uuid */ + segment_id?: string; + subject: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @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. */ - updatedAt: string; + topic_id?: string | null; }; - /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ - WebhookCreateResponse: { + /** @description Acknowledgement that a campaign was deleted. */ + CampaignV1Deleted: { /** @enum {boolean} */ - success: true; - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { - /** @description Plaintext shared secret. Returned ONCE on create. */ - secret: string; - }; + deleted: true; + /** Format: uuid */ + id: string; }; - /** @description List of webhooks for the auth'd project. */ - WebhookListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Webhook"][]; + /** @description A campaign recipient whose send did not complete. */ + CampaignV1Failure: { + /** Format: uuid */ + contact_id: string; + /** @description The recipient the send was for. */ + email: string; + /** Format: date-time */ + failed_at: string; + /** + * Format: uuid + * @description Ledger row id. Pass the last one as `after` to page. + */ + id: string; + reason: string | null; }; - /** @description Single webhook (no secret). */ - WebhookGetResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Webhook"]; + /** @description Cursor-paginated list of a campaign's failed sends. */ + CampaignV1FailureList: { + data: components["schemas"]["CampaignV1Failure"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + /** @description Every FAILED row on this campaign, not just this page. */ + total: number; }; - /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ - WebhookRotateSecretResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - id: string; - /** @description New plaintext shared secret. */ - secret: string; - }; + /** @description Cursor-paginated list of campaigns. */ + CampaignV1List: { + data: components["schemas"]["CampaignV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description An attempted webhook delivery. */ - WebhookCall: { + /** @description Acknowledgement that a retry of a campaign's failed sends began. */ + CampaignV1RetryFailed: { /** Format: uuid */ id: string; - /** Format: uuid */ - webhookId: string; - eventType: string; - payload: { - [key: string]: unknown; - }; - /** @enum {string} */ - status: "PENDING" | "SUCCESS" | "FAILED"; - attempt: number; - responseStatus?: number | null; - responseBody?: string | null; + /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ + queued: number; + }; + /** @description Body for POST /api/v1/campaigns/{id}/send. */ + CampaignV1Send: { /** * Format: date-time - * @description ISO 8601 datetime string + * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. */ - createdAt: string; - }; - /** @description Cursor-paginated list of recent calls for a single webhook. */ - WebhookCallsListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["WebhookCall"][]; - nextCursor?: string | null; - cursor?: string | null; - hasMore?: boolean; - }; - /** @description Response from POST /api/track. */ - TrackEventResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - contact: string; - /** Format: uuid */ - event: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - timestamp: string; - }; - }; - /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ - VerifyEmailResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: email */ - email: string; - valid: boolean; - reason?: string; - } & { - [key: string]: unknown; - }; - }; - /** @description Cursor-paginated list of emails. */ - EmailListResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Email"][]; - nextCursor?: string | null; - }; - /** @description A single email. */ - EmailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Email"]; - }; - /** @description One email and its delivery history. */ - EmailDetailResponse: { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["EmailWithEvents"]; - }; - /** @description Result of a list-subscribe call. */ - ListSubscribeResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: uuid */ - membershipId: string; - /** @enum {string} */ - status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; - /** @description True when the membership row did not exist before this call. */ - created: boolean; - /** - * @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 {string|null} - */ - previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; - /** @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. */ - confirmToken?: string; - }; + scheduled_for?: string; }; - /** @description Echoes the address that was unsubscribed. */ - ListUnsubscribeResponse: { - /** @enum {boolean} */ - success: true; - data: { - /** Format: email */ - email: string; - }; + /** @description Materialized delivery and engagement counters for one campaign. */ + CampaignV1Stats: { + bounce_rate: number; + bounced: number; + click_rate: number; + clicked: number; + delivered: number; + delivery_rate: number; + open_rate: number; + opened: number; + sent: number; + total_recipients: number; }; - /** @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. */ - ApiKey: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - /** @description Last 4 characters of the token — the only fragment of the secret that survives creation. */ - lastFour: string; + /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ + CampaignV1Update: { + audience_condition?: components["schemas"]["FilterConditionV1"]; + /** @enum {string} */ + audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; + body?: string; + description?: string; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** - * @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 {string} + * Format: email + * @description Sender address. Its domain must be verified for this project. */ - legacyGrantPreset: "FULL" | "SENDING_ONLY"; + from?: string; + from_name?: string | null; /** - * @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 {string} + * Format: uuid + * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. */ - mode: "LIVE" | "TEST"; - /** @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. */ - scopes: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + list_id?: string; + name?: string; + /** Format: email */ + reply_to?: string | null; /** Format: uuid */ - domainId: string | null; + segment_id?: string; + subject?: string; /** - * Format: date-time - * @description ISO 8601 datetime string + * Format: uuid + * @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. */ - lastUsedAt: string | null; + topic_id?: string | null; + }; + /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ + ComposeMailboxMessage: { + bcc?: string[]; + body: string; + cc?: string[]; + subject: string; + to: string[]; + }; + /** @description A subscriber/contact within a project. */ + Contact: { /** * Format: date-time * @description ISO 8601 datetime string */ createdAt: string; + customFields?: { + [key: string]: unknown; + } | null; + /** Format: email */ + email: string; + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + subscribed: boolean; /** * Format: date-time - * @description Set once the key is revoked. Revoked keys are NOT filtered out of list/get responses. + * @description ISO 8601 datetime string */ - revokedAt: string | null; + updatedAt: string; }; - /** @description Every API key on the project, including revoked ones — filter on `revokedAt` for live keys. */ - ApiKeyListResponse: { + /** @description Bulk create up to 1000 contacts. */ + ContactBulkCreateBody: { + contacts: components["schemas"]["CreateContact"][]; + }; + /** @description Bulk delete contacts. Provide either `ids` or `emails` (max 1000 each). */ + ContactBulkDeleteBody: { + emails?: string[]; + ids?: string[]; + }; + /** @description Cursor-paginated list of contacts. */ + ContactListResponse: { + data: { + data: components["schemas"]["Contact"][]; + hasMore: boolean; + /** @description Cursor for the next page, or null on the last page. */ + nextCursor: string | null; + total: number; + }; /** @enum {boolean} */ success: true; - data: components["schemas"]["ApiKey"][]; }; - ProjectRecord: { + /** @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. */ + ContactTopicPreferencesV1: { + contact_id: string; + /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ + subscribed: boolean; + topics: { + /** @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. */ + key: string; + name: string; + pending: boolean; + /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ + subscribed: boolean; + topic_id: string; + }[]; + }; + /** @description A contact as exposed on the v1 API. */ + ContactV1: { + /** Format: date-time */ + created_at: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + email: string; /** Format: uuid */ id: string; - name: string; - disabled: boolean; - disabledReason: string | null; - /** @description Local-part of the sandbox quick-start sender; null until first derived. */ - sandboxHandle: string | null; - stripeCustomerId: string | null; - stripeSubscriptionId: string | null; - billingLimitWorkflows: number | null; - billingLimitCampaigns: number | null; - billingLimitTransactional: number | null; - billingLimitInbound: number | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - sesRegion: string | null; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; - organizationId: string | null; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - createdAt: string; - /** - * Format: date-time - * @description ISO 8601 datetime string - */ - updatedAt: string; + subscribed: boolean; + /** Format: date-time */ + updated_at: string; }; - /** @description Cursor-paginated list of campaigns. */ - CampaignV1List: { - data: components["schemas"]["CampaignV1"][]; + /** @description Body for POST /api/v1/contacts. */ + ContactV1Create: { + /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + }; + /** @description Acknowledgement that a contact was deleted. */ + ContactV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of contacts. */ + ContactV1List: { + data: components["schemas"]["ContactV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A campaign as exposed on the v1 API. */ - CampaignV1: { + /** @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. */ + ContactV1Update: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + subscribed?: boolean; + }; + CreateApiKeyBody: { /** Format: uuid */ - id: string; - name: string; - /** @enum {string} */ - status: "DRAFT" | "SCHEDULED" | "SENDING" | "PAUSED" | "SENT" | "CANCELLED"; - subject: string; + domainId?: string | null; /** @enum {string} */ - audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - /** Format: uuid */ - list_id: string | null; - /** Format: uuid */ - topic_id: string | null; - /** Format: date-time */ - scheduled_at: string | null; - /** Format: date-time */ - sent_at: string | null; - /** Format: date-time */ - created_at: string; - stats: { - total_recipients: number; - sent: number; - delivered: number; - opened: number; - clicked: number; - bounced: number; - }; - }; - /** @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`. */ - CampaignV1Create: { - name: string; - description?: string; - subject: string; - body: string; - /** - * Format: email - * @description Sender address. Its domain must be verified for this project. - */ - from: string; - from_name?: string | null; - /** Format: email */ - reply_to?: string | null; + legacyGrantPreset?: "FULL" | "SENDING_ONLY"; /** - * @default MARKETING + * @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 {string} */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + mode?: "LIVE" | "TEST"; + name: string; + /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ + scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + }; + /** @description Body for POST /api/mailboxes/:id/app-passwords. */ + CreateAppPassword: { + name: string; /** - * @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 {string} + * @default [ + * "imap", + * "smtp" + * ] */ - audience_type: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** Format: uuid */ - segment_id?: string; + scopes: ("imap" | "smtp")[]; + }; + /** @description Body for POST /api/contacts and /api/contacts/upsert. */ + CreateContact: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + customFields?: { + [key: string]: unknown; + }; + /** Format: email */ + email: string; + /** @default true */ + subscribed: boolean; + }; + CreateMailboxBody: { + displayName?: string; /** * Format: uuid - * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. + * @description A VERIFIED domain belonging to this project. */ - list_id?: string; + domainId: string; + /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ + localPart: string; /** * Format: uuid - * @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. + * @description Defaults to the project the credential resolves to. Naming a different one is refused. */ - topic_id?: string | null; - }; - /** @description A filter condition: one or more groups combined with `logic`. */ - FilterConditionV1: { - /** @enum {string} */ - logic: "AND" | "OR"; - groups: components["schemas"]["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. */ - FilterGroupV1: { - filters: components["schemas"]["SegmentFilterV1"][]; - conditions?: components["schemas"]["FilterConditionV1"]; + projectId?: string; + /** @description NOT IMPLEMENTED — sending any value answers 400. */ + quotaBytes?: number; }; - /** @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). */ - SegmentFilterV1: { - field: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; - value?: unknown; - /** @enum {string} */ - unit?: "days" | "hours" | "minutes"; + /** @description Body for POST /api/snippets. */ + CreateSnippet: { + body: string; + description?: string | null; + name: string; }; - /** @description Body for PATCH /api/v1/campaigns/{id}. All fields optional. */ - CampaignV1Update: { - name?: string; + /** @description Body for POST /api/templates. */ + CreateTemplate: { + body: string; description?: string; - subject?: string; - body?: string; /** - * Format: email - * @description Sender address. Its domain must be verified for this project. + * @default MARKETING + * @enum {string} */ - from?: string; - from_name?: string | null; + emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ - reply_to?: string | null; - /** @enum {string} */ - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - /** @enum {string} */ - audience_type?: "ALL" | "FILTERED" | "SEGMENT" | "LIST"; - audience_condition?: components["schemas"]["FilterConditionV1"]; - /** Format: uuid */ - segment_id?: string; - /** - * Format: uuid - * @description Required when `audience_type` is `LIST`. Only that list's CONFIRMED members receive the campaign. - */ - list_id?: string; - /** - * Format: uuid - * @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. - */ - topic_id?: string | null; + from: string; + fromName?: string | null; + name: string; + /** Format: email */ + replyTo?: string | null; + subject: string; }; - /** @description Acknowledgement that a campaign was deleted. */ - CampaignV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; + /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ + CreateWebhook: { + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** Format: uri */ + url: string; }; - /** @description Body for POST /api/v1/campaigns/{id}/send. */ - CampaignV1Send: { - /** + /** @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. */ + DeliverabilityDiagnosisV1: { + address: string | null; + /** Format: date-time */ + checked_at: string; + domain: string; + /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ + findings: components["schemas"]["DeliverabilityFindingV1"][]; + identity: components["schemas"]["DeliverabilityIdentityV1"]; + recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; + suppression: components["schemas"]["DeliverabilitySuppressionV1"]; + }; + /** + * @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 {string} + */ + DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; + /** @description One diagnosed problem, with its fix. */ + DeliverabilityFindingV1: { + /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ + code: string; + /** @description What to do about it. */ + remedy: string; + severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; + /** @description What is wrong, in one sentence. */ + summary: string; + }; + /** @description The sending identity's DNS health, as last refreshed. */ + DeliverabilityIdentityV1: { + /** + * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. + * @enum {string|null} + */ + dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** + * @description The DMARC policy published at `_dmarc.`. + * @enum {string|null} + */ + dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** * Format: date-time - * @description RFC 3339 timestamp, strictly in the future. A numeric UTC offset (`+02:00`) is accepted as well as `Z`. Omit to start sending immediately. + * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. */ - scheduled_for?: string; + last_checked_at: string | null; + mail_from_domain: string | null; + /** @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. */ + mail_from_domain_status: string | null; + /** + * @description Inbound receiving only. Null unless the domain has receiving enabled. + * @enum {string|null} + */ + mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description Whether this project has a domain record at all. False makes every other field null. */ + registered: boolean; + /** + * @description SPF alignment for the sending identity. + * @enum {string|null} + */ + spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + verified: boolean; }; - /** @description Materialized delivery and engagement counters for one campaign. */ - CampaignV1Stats: { - total_recipients: number; - sent: number; - delivered: number; - opened: number; - clicked: number; + /** @description Delivery outcomes over the requested window. */ + DeliverabilityRecentDeliveryV1: { + /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ + bounce_rate: number | null; bounced: number; - open_rate: number; - click_rate: number; - bounce_rate: number; - delivery_rate: number; - }; - /** @description Cursor-paginated list of a campaign's failed sends. */ - CampaignV1FailureList: { - data: components["schemas"]["CampaignV1Failure"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - /** @description Every FAILED row on this campaign, not just this page. */ - total: number; - }; - /** @description A campaign recipient whose send did not complete. */ - CampaignV1Failure: { + complained: number; + complaint_rate: number | null; + delivered: number; + failed: number; /** - * Format: uuid - * @description Ledger row id. Pass the last one as `after` to page. + * @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 {string} */ - id: string; - /** Format: uuid */ - contact_id: string; - /** @description The recipient the send was for. */ - email: string; - reason: string | null; - /** Format: date-time */ - failed_at: string; + scope: "project"; + sent: number; + window_days: number; }; - /** @description Acknowledgement that a retry of a campaign's failed sends began. */ - CampaignV1RetryFailed: { - /** Format: uuid */ + /** @description Null unless the request named an `address`. */ + DeliverabilitySuppressionV1: { + /** @enum {string|null} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; + /** @enum {string|null} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; + suppressed: boolean; + /** Format: date-time */ + suppressed_at: string | null; + } | null; + /** @description One DMARC aggregate (RUA) report. */ + DmarcReportV1: { + fail_count: number; id: string; - /** @description How many FAILED rows the retry walk was started for, counted when it was queued. */ - queued: number; + /** @description The reporting receiver, e.g. `google.com`. */ + org_name: string; + /** @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. */ + pass_count: number; + /** @description The domain of yours the report is about. */ + policy_domain: string; + /** Format: date-time */ + range_begin: string; + /** Format: date-time */ + range_end: string; + /** Format: date-time */ + received_at: string; + /** @description The receiver's own id for this report. */ + report_id: string; + /** @description Per-sending-source rows, as the receiver reported them. */ + sources: { + count: number; + disposition: string; + dkim: string; + header_from: string; + source_ip: string; + spf: string; + }[]; + total_count: number; }; - /** @description Cursor-paginated list of segments. */ - SegmentV1List: { - data: components["schemas"]["SegmentV1"][]; + /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ + DmarcReportV1List: { + data: components["schemas"]["DmarcReportV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A segment as exposed on the v1 API. */ - SegmentV1: { + /** @description A sending identity: one domain registered with SES, with its own DKIM keys, its own MAIL FROM and its own reputation. */ + Domain: { + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ + defaultFromAddress?: string | null; + dkim?: { + name: string; + type: string; + value: string; + }[]; /** Format: uuid */ id: string; + /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; name: string; - description: string | null; - /** @enum {string} */ - type: "DYNAMIC" | "STATIC"; - condition: components["schemas"]["FilterConditionV1"] | null; - track_membership: boolean; - member_count: number; + /** Format: uuid */ + projectId: string; + region?: string | null; + /** + * @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 {string|null} + */ + stream?: "TRANSACTIONAL" | "MARKETING" | null; + /** @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). */ + streamDefault?: boolean; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + verified: boolean; + }; + /** @description List of all domains for the auth'd project. */ + DomainListResponse: { + data: components["schemas"]["Domain"][]; + /** @enum {boolean} */ + success: true; + }; + /** @description A sending domain as exposed on the v1 API. */ + DomainV1: { /** Format: date-time */ created_at: string; + default_from_address: string | null; + dkim_verified: boolean; + domain: string; + /** Format: uuid */ + id: string; + mail_from_domain: string | null; + /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ + mail_from_domain_status: string | null; + region: string | null; + stream: components["schemas"]["SendingStream"] & (string | null); + stream_default: boolean; /** Format: date-time */ updated_at: string; + verified: boolean; }; - /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ - SegmentV1Create: { - name: string; - description?: string; + /** @description Body for POST /api/v1/domains. */ + DomainV1Create: { + domain: string; /** - * @default DYNAMIC + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. * @enum {string} */ - type: "DYNAMIC" | "STATIC"; - condition?: components["schemas"]["FilterConditionV1"]; - /** - * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. - * @default false - */ - track_membership: boolean; - }; - /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ - SegmentV1Update: { - name?: string; - description?: string; - condition?: components["schemas"]["FilterConditionV1"]; - track_membership?: boolean; + region?: "us-east-1" | "us-west-2" | "eu-west-1"; + stream?: components["schemas"]["SendingStream"] & unknown; + /** @description Make this the project's default identity for `stream`. Requires `stream`. */ + stream_default?: boolean; }; - /** @description Acknowledgement that a segment was deleted. */ - SegmentV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Acknowledgement that a sending domain was removed. */ + DomainV1Deleted: { /** @enum {boolean} */ deleted: true; + /** Format: uuid */ + id: string; }; - /** @description Cursor-paginated list of the contacts belonging to a segment. */ - SegmentContactV1List: { - data: components["schemas"]["SegmentContactV1"][]; + /** @description Cursor-paginated list of sending domains. */ + DomainV1List: { + data: components["schemas"]["DomainV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @description A contact belonging to a segment. */ - SegmentContactV1: { - /** Format: uuid */ - id: string; - email: string; - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: { - [key: string]: unknown; - }; - /** Format: date-time */ - created_at: string; + /** @description Outcome of a verification check against SES. */ + DomainVerificationStatus: { + dkim?: { + name: string; + type: string; + value: string; + }[]; + mailFromDomain?: string | null; + /** + * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. + * @enum {string|null} + */ + mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; + mxRecords?: string[]; + verified: boolean; }; - /** @description Cursor-paginated list of workflows. */ - WorkflowV1List: { - data: components["schemas"]["WorkflowV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ + DraftMailboxMessage: { + brief?: string; + draft?: string; + instruction?: string; + /** @enum {string} */ + mode: "draft" | "rewrite" | "subject"; + recipientContext?: string; + senderAddress?: string; + /** @enum {string} */ + tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; }; - /** @description An automation workflow as exposed on the v1 API. */ - WorkflowV1: { + /** @description A sent (or queued) transactional email. */ + Email: { + /** + * Format: date-time + * @description Bounced, or null. + */ + bouncedAt: string | null; + /** + * Format: date-time + * @description First click, or null. + */ + clickedAt: string | null; + /** @description Total clicks recorded. */ + clicks: number; + /** + * Format: date-time + * @description Spam complaint, or null. + */ + complainedAt: string | null; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description Accepted by the recipient's server, or null. + */ + deliveredAt: string | null; + error?: string | null; + from: string; /** Format: uuid */ id: string; - name: string; - description: string | null; - enabled: boolean; - /** @enum {string} */ - trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; - /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ - event_name: string | null; - allow_reentry: boolean; - max_executions_per_hour: number | null; - /** @description Incremented on every structural (step/transition) change. */ - version: number; - /** @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. */ - step_count: number; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description Body for POST /api/v1/workflows. */ - WorkflowCreateV1: { - name: string; - description?: string; - trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; - /** @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. */ - event_name?: string; - /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ - interval_ms?: number; - /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ - enabled?: boolean; - allow_reentry?: boolean; - /** @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. */ - sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; - }; - /** - * @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 {string} - */ - WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; - /** @description One step of a linear workflow sequence. */ - WorkflowSequenceStepV1: { - type: components["schemas"]["WorkflowSequenceStepTypeV1"]; - /** @description Human-readable label, e.g. `Day 0: welcome`. */ - name: string; - /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ - config: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; /** - * Format: uuid - * @description For `SEND_EMAIL`: a template in this project. + * Format: date-time + * @description First open, or null. */ - template_id?: string; + openedAt: string | null; + /** @description Total opens recorded. */ + opens: number; + /** Format: uuid */ + projectId: string; + /** + * Format: date-time + * @description Handed to the provider, or null. + */ + sentAt: string | null; + status: components["schemas"]["EmailDeliveryStatus"]; + subject: string; + tags: string[]; + to: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; }; /** - * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @description Delivery lifecycle of the message. Engagement is reported separately. * @enum {string} */ - WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; - /** @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. */ - WorkflowUpdateV1: { - name?: string; - description?: string; - trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; - event_name?: string; - /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ - interval_ms?: number; - enabled?: boolean; - allow_reentry?: boolean; - /** @description Per-workflow start rate cap. `null` removes the cap. */ - max_executions_per_hour?: number | null; - /** @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. */ - sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + EmailDeliveryStatus: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** @description One email and its delivery history. */ + EmailDetailResponse: { + data: components["schemas"]["EmailWithEvents"]; + /** @enum {boolean} */ + success: true; }; - /** @description Confirmation that a workflow was deleted. */ - WorkflowDeletedV1: { + /** @description One transition in a message's delivery history. */ + EmailEvent: { /** Format: uuid */ id: string; + status: components["schemas"]["EmailDeliveryStatus"]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description Cursor-paginated list of emails. */ + EmailListResponse: { + data: components["schemas"]["Email"][]; + nextCursor?: string | null; /** @enum {boolean} */ - deleted: true; + success: true; }; - /** @description Cursor-paginated list of workflow executions, newest first. */ - WorkflowExecutionV1List: { - data: components["schemas"]["WorkflowExecutionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description A single email. */ + EmailResponse: { + data: components["schemas"]["Email"]; + /** @enum {boolean} */ + success: true; }; - /** @description One contact's run through a workflow. */ - WorkflowExecutionV1: { - /** Format: uuid */ + /** @description Receipt for a sandbox test send. */ + EmailTestV1: { + /** + * Format: email + * @description This project's sandbox sender — resolved server-side, never from the body. + */ + from: string; + /** + * Format: uuid + * @description The Email row this send created. + */ id: string; - /** Format: uuid */ - workflow_id: string; - /** Format: uuid */ - contact_id: string; - /** @enum {string} */ - status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - /** Format: uuid */ - current_step_id: string | null; - exit_reason: string | null; - /** Format: date-time */ - started_at: string; - /** Format: date-time */ - completed_at: string | null; + /** + * @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 {boolean} + */ + sandbox: true; + /** + * @description Delivery status at the moment of the response — `PENDING` for a send still queued. + * @enum {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; }; - /** @description Body for POST /api/v1/workflows/{id}/executions. */ - WorkflowExecutionStartV1: { + /** @description Receipt for a single transactional send. */ + EmailV1: { + /** + * Format: email + * @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. + */ + from: string; /** * Format: uuid - * @description Contact to enter the workflow. Must belong to this project. + * @description The Email row this send created. Quote it in support requests. */ - contact_id: string; - /** @description Extra variables merged into the contact's data for this run. */ - context?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + id: string; + /** + * @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 {string} + */ + status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; + /** + * Format: email + * @description The recipient the message was queued for. + */ + to: string; }; - /** @description Execution, email and conversion totals for one workflow. */ - WorkflowStatsV1: { - /** Format: uuid */ - workflow_id: string; - name: string; - enabled: boolean; - trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; - /** @description Steps in the workflow's graph, trigger step included. */ - step_count: number; - total: number; - /** @description Execution counts keyed by status; a status with no executions is absent. */ - by_status: { - [key: string]: number; - }; - /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ - completion_rate: number | null; - avg_duration_ms: number | null; - emails: { - sent: number; - opened: number; - clicked: number; + EmailValidationBatchRequestV1: { + /** @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. */ + emails: string[]; + }; + /** @description One verdict per address, in the order they were given. */ + EmailValidationBatchV1: { + results: components["schemas"]["EmailValidationV1"][]; + }; + /** @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. */ + EmailValidationResultListV1: { + data: (components["schemas"]["EmailValidationV1"] & { + contact_id: string | null; + })[]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description One bulk validation run over a list. */ + EmailValidationRunV1: { + /** Format: date-time */ + completed_at: string | null; + /** Format: date-time */ + created_at: string; + deliverable_count: number; + /** @description Set only on `failed`. Prose for an operator; never parse it. */ + failure_reason: string | null; + id: string; + list_id: string | null; + /** @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. */ + processed_count: number; + risky_count: number; + /** Format: date-time */ + started_at: string | null; + /** @enum {string} */ + status: "pending" | "running" | "completed" | "failed"; + undeliverable_count: number; + }; + /** @description One address's verdict, with the evidence behind it. */ + EmailValidationV1: { + email: string; + /** @description The domain publishes MX records. */ + has_mx_records: boolean; + /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ + is_disposable: boolean; + /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ + is_personal: boolean; + /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ + is_role_address: boolean; + /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ + reasons: string[]; + verdict: components["schemas"]["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 {string} + */ + EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; + /** @description A transactional email together with its delivery history. */ + EmailWithEvents: components["schemas"]["Email"] & { + /** @description Delivery transitions for this message, oldest first. NOT the custom events recorded with `POST /api/v1/events` — those are a separate resource. */ + events: components["schemas"]["EmailEvent"][]; + }; + /** @description Standard error envelope returned by all 4xx/5xx responses. Migrated routes include `success: false`; 422 validation errors add `error.details.errors`. */ + Error: { + error: { + code: string; + details?: { + errors: unknown[]; + }; + message: string; }; - conversions: { - /** Format: uuid */ - goal_id: string; - name: string; - event_name: string; + /** @enum {boolean} */ + success?: false; + }; + /** @description Every distinct event name in the project, most frequent first. */ + EventNamesV1: { + data: string[]; + }; + /** @description Per-name event counts over the applied window. */ + EventStatsV1: { + data: { count: number; + name: string; }[]; + window: components["schemas"]["AnalyticsWindowV1"]; }; - /** @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. */ - WorkflowGraphV1: { - /** Format: uuid */ - workflow_id: string; - /** @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. */ - version: number; - steps: components["schemas"]["WorkflowStepReadV1"][]; - transitions: components["schemas"]["WorkflowTransitionV1"][]; - }; - /** @description One node of a workflow graph, as read. */ - WorkflowStepReadV1: { + /** @description Body for POST /api/v1/events. */ + EventTrackV1: { /** * Format: uuid - * @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. + * @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. */ - id: string; + contact_id?: string; + /** @description Event name, e.g. `user.signup`. */ name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** @enum {string} */ - type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; - /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ - config: { + /** @description Arbitrary event payload. */ + payload?: { [key: string]: string | number | boolean | { [key: string]: unknown; } | unknown[] | null; }; }; - /** @description Where this step sits on the editor canvas. */ - WorkflowStepPositionV1: { - x: number; - y: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - /** @description One directed edge between two steps. */ - WorkflowTransitionV1: { - /** - * Format: uuid - * @description Caller-chosen on a write, exactly like a step id. - */ - id: string; + /** @description A recorded custom event. */ + EventV1: { /** Format: uuid */ - from_step_id: string; + contact_id: string | null; + /** Format: date-time */ + created_at: string; /** Format: uuid */ - to_step_id: string; - /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ - condition: string | number | boolean | { + email_id: string | null; + /** Format: uuid */ + id: string; + name: string; + /** @description The payload recorded with the event, or null. */ + payload: { [key: string]: unknown; - } | unknown[] | null; - /** @description Evaluation order among the edges leaving one step; lowest first. */ - priority: number; + } | null; }; - /** @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. */ - WorkflowGraphReplaceV1: { - /** @description The complete step set. Exactly one must be a `TRIGGER`. */ - steps: components["schemas"]["WorkflowStepV1"][]; - /** @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. */ - transitions: components["schemas"]["WorkflowTransitionV1"][]; + /** @description Cursor-paginated list of events, newest first. */ + EventV1List: { + data: components["schemas"]["EventV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description One node of a workflow graph. */ - WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; - /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ - WorkflowTriggerStepV1: { - /** - * Format: uuid - * @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. - */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "TRIGGER"; - config: { - eventName?: string; - intervalMs?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description A filter condition: one or more groups combined with `logic`. */ + FilterConditionV1: { + groups: components["schemas"]["FilterGroupV1"][]; + /** @enum {string} */ + logic: "AND" | "OR"; + }; + /** @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. */ + FilterGroupV1: { + conditions?: components["schemas"]["FilterConditionV1"]; + filters: components["schemas"]["SegmentFilterV1"][]; + }; + /** @description Success envelope carrying the affected resource's id, e.g. after a delete. */ + IdResponse: { + data: { + /** Format: uuid */ + id: string; }; + /** @enum {boolean} */ + success: true; }; - /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ - WorkflowSendEmailStepV1: { + /** @description Body for POST /api/lists/{id}/subscribe. */ + ListSubscribe: { /** - * Format: uuid - * @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. + * @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. + * @default false */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "SEND_EMAIL"; - config: { + allowResubscribe: boolean; + /** @description Custom fields to upsert onto the contact as part of subscribing. */ + data?: { + [key: string]: unknown; + }; + /** Format: email */ + email: string; + }; + /** @description Result of a list-subscribe call. */ + ListSubscribeResponse: { + data: { + /** @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. */ + confirmToken?: string; + /** @description True when the membership row did not exist before this call. */ + created: boolean; /** Format: uuid */ - templateId?: string; - subject?: string; - body?: string; - recipient?: { - /** @enum {string} */ - type: "CONTACT" | "CUSTOM"; - /** Format: email */ - customEmail?: string; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + membershipId: string; + /** + * @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 {string|null} + */ + previousStatus: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED" | null; + /** @enum {string} */ + status: "PENDING" | "CONFIRMED" | "UNSUBSCRIBED"; }; + /** @enum {boolean} */ + success: true; }; - /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ - WorkflowDelayStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Body for POST /api/lists/{id}/unsubscribe. */ + ListUnsubscribe: { + /** Format: email */ + email: string; + }; + /** @description Echoes the address that was unsubscribed. */ + ListUnsubscribeResponse: { + data: { + /** Format: email */ + email: string; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description A subscriber list as exposed on the v1 API. */ + ListV1: { + /** Format: uuid */ + confirmation_template_id: string | null; + /** Format: date-time */ + created_at: string; + description: string | null; + double_opt_in: boolean; + /** Format: uuid */ id: string; + /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ + member_count: number; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + redirect_url: string | null; + /** Format: date-time */ + updated_at: string; + }; + /** @description Body for POST /api/v1/lists. */ + ListV1Create: { + /** Format: uuid */ + confirmation_template_id?: string | null; + description?: string | null; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @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. + * @default false */ - template_id?: string | null; + double_opt_in: boolean; + name: string; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Format: uri + * @description Where a confirmed contact is sent after following the confirmation link. */ - type: "DELAY"; - config: { - amount?: number; - /** @enum {string} */ - unit?: "minutes" | "hours" | "days"; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + redirect_url?: string | null; }; - /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ - WorkflowWaitForEventStepV1: { + /** @description Acknowledgement that a list was deleted. */ + ListV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of subscriber lists. */ + ListV1List: { + data: components["schemas"]["ListV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Body for PATCH /api/v1/lists/{id}. */ + ListV1Update: { + /** Format: uuid */ + confirmation_template_id?: string | null; + description?: string | null; + double_opt_in?: boolean; + name?: string; + /** Format: uri */ + redirect_url?: string | null; + }; + /** @description A receiving mailbox on one of the project's verified domains. */ + Mailbox: { /** - * Format: uuid - * @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: email + * @description The full mailbox address, e.g. `support@superbooks.io`. */ - id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + address: string; + /** Format: date-time */ + createdAt: string; + displayName: string | null; /** * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @description The verified domain this mailbox lives on. */ - template_id?: string | null; + domainId: string; + /** Format: uuid */ + id: string; + /** @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. */ + quotaBytes: number | null; /** - * @description discriminator enum property added by openapi-typescript + * @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 {string} */ - type: "WAIT_FOR_EVENT"; - config: { - eventName?: string; - timeout?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; + }; + /** @description A mailbox plus its IMAP/SMTP connection settings. */ + MailboxDetail: components["schemas"]["Mailbox"] & { + /** @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. */ + settings: { + imap: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; + smtp: { + host: string; + port: number; + /** @description Transport security, e.g. `SSL/TLS`. */ + security: string; + /** @description The mailbox address — it is also the login. */ + username: string; + }; }; }; - /** @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. */ - WorkflowConditionStepV1: { + /** @description RFC 9457 problem document, served as `application/problem+json`. Returned by every 4xx/5xx response on the `/api/v1` surface. */ + Problem: { + /** @description Machine-readable lowercase error code, e.g. `scope_missing`. */ + code: string; + /** @description Explanation specific to this occurrence. */ + detail?: string; + /** @description Field-level failures. Present on 422 `validation_error` responses. */ + errors?: { + code: string; + message: string; + /** @description RFC 6901 JSON Pointer to the offending field. */ + pointer: string; + }[]; + /** @description Request path the failure occurred on. */ + instance?: string; + /** @description Correlation id — quote it in support requests. */ + request_id?: string; + /** @description HTTP status code, repeated in the body. */ + status: number; + /** @description Short, stable summary — the same for every occurrence of a `type`. */ + title: string; /** - * Format: uuid - * @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: uri + * @description Dereferenceable URI identifying the error class, anchored on the docs errors page. + */ + type: string; + }; + ProjectRecord: { + billingLimitCampaigns: number | null; + billingLimitInbound: number | null; + billingLimitTransactional: number | null; + billingLimitWorkflows: number | null; + /** + * Format: date-time + * @description ISO 8601 datetime string */ + createdAt: string; + disabled: boolean; + disabledReason: string | null; + /** Format: uuid */ id: string; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; + organizationId: string | null; + /** @description Local-part of the sandbox quick-start sender; null until first derived. */ + sandboxHandle: string | null; + sesRegion: string | null; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * Format: date-time + * @description ISO 8601 datetime string */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "CONDITION"; - config: { - /** @enum {string} */ - mode?: "multi"; - field?: string; - /** @enum {string} */ - operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - value?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - branches?: ({ - id: string; - name: string; - /** @enum {string} */ - operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - value?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - })[]; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + updatedAt: string; }; - /** @description Ends the run early and stamps `exit_reason`. */ - WorkflowExitStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description The project the presented credential is scoped to. */ + ProjectV1: { + /** Format: date-time */ + created_at: string; + /** @description A disabled project sends nothing; every send is refused. */ + disabled: boolean; + /** Format: uuid */ id: string; + /** @description ISO 639-1 code for customer-facing content. */ + language: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; + /** @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. */ + sandbox_address: string | null; + /** @description Locked once the first domain is added. */ + ses_region: string | null; + /** @enum {string} */ + tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; + }; + /** @description Delivery outcomes for one recipient domain on one day. */ + RecipientDomainStatsV1: { + bounced: number; + complained: number; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Format: date-time + * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. */ - type: "EXIT"; - config: { - reason?: string; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + computed_at: string; + /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ + day: string; + delivered: number; + /** @description The recipient's domain, lowercased: the part after the `@`. */ + domain: string; + opened: number; + sent: number; + }; + /** @description Cursor-paginated recipient-domain rollup, newest day first. */ + RecipientDomainStatsV1List: { + data: components["schemas"]["RecipientDomainStatsV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A contact belonging to a segment. */ + SegmentContactV1: { + /** Format: date-time */ + created_at: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + custom_fields: { + [key: string]: unknown; }; + email: string; + /** Format: uuid */ + id: string; + subscribed: boolean; }; - /** @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. */ - WorkflowWebhookStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Cursor-paginated list of the contacts belonging to a segment. */ + SegmentContactV1List: { + data: components["schemas"]["SegmentContactV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @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). */ + SegmentFilterV1: { + field: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists" | "within" | "olderThan" | "triggered" | "triggeredWithin" | "triggeredOlderThan" | "notTriggered" | "notTriggeredWithin" | "isMemberOf"; + /** @enum {string} */ + unit?: "days" | "hours" | "minutes"; + value?: unknown; + }; + /** @description A segment as exposed on the v1 API. */ + SegmentV1: { + condition: components["schemas"]["FilterConditionV1"] | null; + /** Format: date-time */ + created_at: string; + description: string | null; + /** Format: uuid */ id: string; + member_count: number; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "WEBHOOK"; - config: { - /** Format: uri */ - url?: string; - /** @enum {string} */ - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - headers?: { - [key: string]: string; - }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - body?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + track_membership: boolean; + /** @enum {string} */ + type: "DYNAMIC" | "STATIC"; + /** Format: date-time */ + updated_at: string; }; - /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ - WorkflowUpdateContactStepV1: { - /** - * Format: uuid - * @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. - */ - id: string; + /** @description Body for POST /api/v1/segments. `condition` is required when `type` is `DYNAMIC`. */ + SegmentV1Create: { + condition?: components["schemas"]["FilterConditionV1"]; + description?: string; name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + * @description Emit segment entry/exit events as contacts move in and out. Off by default — it costs a membership write per transition. + * @default false */ - template_id?: string | null; + track_membership: boolean; /** - * @description discriminator enum property added by openapi-typescript + * @default DYNAMIC * @enum {string} */ - type: "UPDATE_CONTACT"; - config: { - updates?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; - subscribed?: boolean; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; + type: "DYNAMIC" | "STATIC"; }; - /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ - WorkflowSendAtOptimalTimeStepV1: { - /** - * Format: uuid - * @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. - */ + /** @description Acknowledgement that a segment was deleted. */ + SegmentV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ id: string; - name: string; - position: components["schemas"]["WorkflowStepPositionV1"]; - /** - * Format: uuid - * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. - */ - template_id?: string | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "SEND_AT_OPTIMAL_TIME"; - config: { - /** Format: uuid */ - templateId?: string; - fallbackHour?: number; - maxDelayHours?: number; - } & { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - }; }; - /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ - WorkflowCloneV1: { - /** @description Name for the copy. Defaults to `Copy of `. */ - name?: string; + /** @description Cursor-paginated list of segments. */ + SegmentV1List: { + data: components["schemas"]["SegmentV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ - WorkflowStateChangeV1: { - workflow: components["schemas"]["WorkflowV1"]; - /** @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). */ - cancelled_executions: number; + /** @description Body for PATCH /api/v1/segments/{id}. `type` is deliberately absent — it is fixed at creation. `condition` is ignored on a STATIC segment. */ + SegmentV1Update: { + condition?: components["schemas"]["FilterConditionV1"]; + description?: string; + name?: string; + track_membership?: boolean; }; - /** @description Receipt for a single transactional send. */ - EmailV1: { - /** - * Format: uuid - * @description The Email row this send created. Quote it in support requests. - */ - id: string; - /** - * @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 {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; - /** - * Format: email - * @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. - */ - from: string; - }; - /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ - SendEmailV1: { - subject?: string; + /** @description Body for POST /api/emails — send a single transactional email. Either `template` or `subject`+`body` is required. */ + SendEmail: { + attachments?: { + content: string; + contentId?: string; + contentType: string; + /** + * @default attachment + * @enum {string} + */ + disposition: "attachment" | "inline"; + filename: string; + }[]; + bcc?: string[]; body?: string; - /** Format: uuid */ - template?: string; - subscribed?: boolean; - name?: string; + cc?: string[]; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; from?: string | { - name?: string; /** Format: email */ email: string; + name?: string; }; - /** Format: email */ - reply?: string; headers?: { [key: string]: string; }; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { - [key: string]: unknown; + name?: string; + /** Format: email */ + reply?: string; + subject?: string; + subscribed?: boolean; + tags?: string[]; + /** Format: uuid */ + template?: string; + to: string | { + /** Format: email */ + email: string; + name?: string; + } | (string | { + /** Format: email */ + email: string; + name?: string; + })[]; + }; + /** @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`. */ + SendEmailData: { + emails: components["schemas"]["SendEmailRecipientResult"][]; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @description Per-recipient result: the upserted `contact` (id + email) and `email` — the id of the queued email record for that recipient. */ + SendEmailRecipientResult: { + contact: { + /** Format: email */ + email: string; + /** Format: uuid */ + id: string; }; + /** Format: uuid */ + email: string; + }; + /** @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. */ + SendEmailResponse: { + data: components["schemas"]["SendEmailData"]; + /** @enum {boolean} */ + success: true; + }; + /** @description Body for POST /api/v1/emails. Either `template` or `subject`+`body` is required, and `to` names exactly one recipient. */ + SendEmailV1: { attachments?: { - filename: string; content: string; - contentType: string; contentId?: string; + contentType: string; /** * @default attachment * @enum {string} */ disposition: "attachment" | "inline"; + filename: string; }[]; - tags?: string[]; - cc?: string[]; bcc?: string[]; + body?: string; + cc?: string[]; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; + from?: string | { + /** Format: email */ + email: string; + name?: string; + }; + headers?: { + [key: string]: string; + }; + name?: string; + /** Format: email */ + reply?: string; + subject?: string; + subscribed?: boolean; + tags?: string[]; + /** Format: uuid */ + template?: string; /** @description The single recipient. Use `cc`/`bcc` to copy others on the same message. */ to: string | { - name?: string; /** Format: email */ email: string; + name?: string; }; }; - /** @description Receipt for a sandbox test send. */ - EmailTestV1: { - /** - * Format: uuid - * @description The Email row this send created. - */ - id: string; - /** - * @description Delivery status at the moment of the response — `PENDING` for a send still queued. - * @enum {string} - */ - status: "PENDING" | "SENDING" | "SENT" | "DELIVERED" | "RECEIVED" | "BOUNCED" | "FAILED" | "REJECTED" | "RENDERING_FAILURE" | "DELIVERY_DELAY" | "CANCELLED"; - /** - * Format: email - * @description The recipient the message was queued for. - */ - to: string; - /** - * Format: email - * @description This project's sandbox sender — resolved server-side, never from the body. - */ - from: string; - /** - * @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 {boolean} - */ - sandbox: true; - }; /** @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. */ SendTestEmailV1: { + /** @description HTML body. Merge tags are rendered as on any other send. */ + body: string; + /** @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. */ + from?: string; + subject: string; /** * Format: email * @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. */ to?: string; - subject: string; - /** @description HTML body. Merge tags are rendered as on any other send. */ - body: string; - /** @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. */ - from?: string; - }; - /** @description Cursor-paginated list of contacts. */ - ContactV1List: { - data: components["schemas"]["ContactV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; }; - /** @description A contact as exposed on the v1 API. */ - ContactV1: { + /** + * @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 {string} + */ + SendingStream: "TRANSACTIONAL" | "MARKETING"; + /** @description A reusable fragment of template markup. */ + Snippet: { + /** @description Template markup. Values it interpolates are escaped like any other. */ + body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + description?: string | null; /** Format: uuid */ id: string; - email: string; - subscribed: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description Body for POST /api/v1/contacts. */ - ContactV1Create: { - /** Format: email */ - email: string; - /** @default true */ - subscribed: boolean; - /** @description Arbitrary JSON stored on the contact and available to templates as `{{ variables }}`. */ - custom_fields?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description The literal identifier a template includes with `{{> name}}`. */ + name: string; + /** Format: uuid */ + projectId: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; }; - /** @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. */ - ContactV1Update: { - subscribed?: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - custom_fields?: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Cursor-paginated list of snippets. */ + SnippetListResponse: { + data: { + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + data: components["schemas"]["Snippet"][]; + hasMore: boolean; + total: number; + }; + /** @enum {boolean} */ + success: true; }; - /** @description Acknowledgement that a contact was deleted. */ - ContactV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Bare success envelope with no payload. */ + SuccessEmpty: { /** @enum {boolean} */ - deleted: true; + success: true; }; - /** @description Body for POST /api/lists/{id}/subscribe. */ - ListSubscribe: { + /** @description A single suppressed-email record. */ + Suppression: { + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; /** Format: email */ email: string; - /** @description Custom fields to upsert onto the contact as part of subscribing. */ - data?: { - [key: string]: unknown; - }; - /** - * @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. - * @default false - */ - allowResubscribe: boolean; - }; - /** @description Body for POST /api/lists/{id}/unsubscribe. */ - ListUnsubscribe: { - /** Format: email */ - email: string; - }; - /** @description Cursor-paginated list of subscriber lists. */ - ListV1List: { - data: components["schemas"]["ListV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description A subscriber list as exposed on the v1 API. */ - ListV1: { /** Format: uuid */ id: string; - name: string; - description: string | null; - double_opt_in: boolean; /** Format: uuid */ - confirmation_template_id: string | null; - redirect_url: string | null; - /** @description Memberships in ANY status, including PENDING and UNSUBSCRIBED ones. */ - member_count: number; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; + projectId: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source: "SES_WEBHOOK" | "API" | "DASHBOARD"; }; - /** @description Body for POST /api/v1/lists. */ - ListV1Create: { - name: string; - description?: string | null; - /** - * @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. - * @default false - */ - double_opt_in: boolean; - /** Format: uuid */ - confirmation_template_id?: string | null; + /** @description Result of GET /api/suppression/{email} — whether the address is suppressed. */ + SuppressionCheckResponse: { /** - * Format: uri - * @description Where a confirmed contact is sent after following the confirmation link. + * Format: date-time + * @description ISO 8601 datetime string */ - redirect_url?: string | null; - }; - /** @description Body for PATCH /api/v1/lists/{id}. */ - ListV1Update: { - name?: string; - description?: string | null; - double_opt_in?: boolean; - /** Format: uuid */ - confirmation_template_id?: string | null; - /** Format: uri */ - redirect_url?: string | null; + createdAt?: string; + /** @enum {string} */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** @enum {string} */ + source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; + suppressed: boolean; }; - /** @description Acknowledgement that a list was deleted. */ - ListV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Cursor-paginated list of suppressions. */ + SuppressionListResponse: { + cursor?: string | null; + data: components["schemas"]["Suppression"][]; + hasMore?: boolean; + nextCursor?: string | null; /** @enum {boolean} */ - deleted: true; - }; - /** @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. */ - AddDomainBody: { - /** Format: uuid */ - projectId?: string; - domain: string; - /** - * @description Override SES region for this domain. Defaults to the project region or the env default. Required to match an existing project region. - * @enum {string} - */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - stream?: components["schemas"]["SendingStream"]; - /** @description Make this the project's default identity for `stream`. Requires `stream`. Setting it demotes whichever identity held it. */ - streamDefault?: boolean; - }; - /** - * @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 {string} - */ - SendingStream: "TRANSACTIONAL" | "MARKETING"; - /** @description Body for PATCH /api/domains/{id}. */ - AssignDomainStream: { - /** - * @description Which traffic this identity carries. `null` unassigns it, returning it to serving every stream and clearing its default flag and default address. - * @enum {string|null} - */ - stream?: "TRANSACTIONAL" | "MARKETING" | null; - /** @description Make this the project's default identity for its stream, demoting whichever held it. */ - streamDefault?: boolean; - /** - * Format: email - * @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. - */ - defaultFromAddress?: string | null; - }; - /** @description Cursor-paginated list of sending domains. */ - DomainV1List: { - data: components["schemas"]["DomainV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + success: true; }; - /** @description A sending domain as exposed on the v1 API. */ - DomainV1: { - /** Format: uuid */ - id: string; - domain: string; - verified: boolean; - region: string | null; - stream: components["schemas"]["SendingStream"] & (string | null); - stream_default: boolean; - default_from_address: string | null; - mail_from_domain: string | null; - /** @description SES's CustomMailFromStatus for `mail_from_domain` — the subdomain that carries the bounce path, NOT the status of any From address. */ - mail_from_domain_status: string | null; - dkim_verified: boolean; + /** @description A suppressed address as exposed on the v1 API. */ + SuppressionV1: { /** Format: date-time */ created_at: string; - /** Format: date-time */ - updated_at: string; + email: string; + /** @enum {string} */ + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + source: string; }; - /** @description Body for POST /api/v1/domains. */ - DomainV1Create: { - domain: string; + /** @description Body for POST /api/v1/suppressions. */ + SuppressionV1Create: { + /** Format: email */ + email: string; /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @default MANUAL * @enum {string} */ - region?: "us-east-1" | "us-west-2" | "eu-west-1"; - stream?: components["schemas"]["SendingStream"] & unknown; - /** @description Make this the project's default identity for `stream`. Requires `stream`. */ - stream_default?: boolean; + reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; - /** @description Acknowledgement that a sending domain was removed. */ - DomainV1Deleted: { - /** Format: uuid */ - id: string; + /** @description Acknowledgement that an address was un-suppressed. */ + SuppressionV1Deleted: { /** @enum {boolean} */ deleted: true; + email: string; }; - /** @description Body for POST /api/templates. */ - CreateTemplate: { - name: string; - description?: string; - subject: string; + /** @description Cursor-paginated list of suppressed addresses. */ + SuppressionV1List: { + data: components["schemas"]["SuppressionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description A reusable email template. */ + Template: { body: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + description?: string | null; + /** @enum {string} */ + emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ from: string; fromName?: string | null; + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + projectId: string; /** Format: email */ replyTo?: string | null; + subject: string; /** - * @default MARKETING - * @enum {string} + * Format: date-time + * @description ISO 8601 datetime string */ - emailCategory: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - }; - /** @description Body for PATCH /api/templates/{id}. */ - UpdateTemplate: { - name?: string; - description?: string; - subject?: string; - body?: string; - /** Format: email */ - from?: string; - fromName?: string | null; - /** Format: email */ - replyTo?: string | null; - /** @enum {string} */ - emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + updatedAt: string; }; /** @description Cursor-paginated list of templates. */ - TemplateV1List: { - data: components["schemas"]["TemplateV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + TemplateListResponse: { + data: { + /** @description Cursor for the next page; omitted on the last page. */ + cursor?: string; + data: components["schemas"]["Template"][]; + hasMore: boolean; + total: number; + }; + /** @enum {boolean} */ + success: true; }; /** @description An email template as exposed on the v1 API. */ TemplateV1: { - /** Format: uuid */ - id: string; - name: string; - description: string | null; - subject: string; body: string; + /** Format: date-time */ + created_at: string; + description: string | null; + /** @enum {string} */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; from: string; from_name: string | null; + /** Format: uuid */ + id: string; + name: string; reply_to: string | null; - /** @enum {string} */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - version: number; - /** Format: date-time */ - created_at: string; + subject: string; /** Format: date-time */ updated_at: string; + version: number; }; /** @description Body for POST /api/v1/templates. */ TemplateV1Create: { - name: string; - description?: string | null; - subject: string; body: string; + description?: string | null; + /** + * @default MARKETING + * @enum {string} + */ + email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** * Format: email * @description Sender address. Its domain must be verified for this project. */ from: string; from_name?: string | null; + name: string; /** Format: email */ reply_to?: string | null; - /** - * @default MARKETING - * @enum {string} - */ - email_category: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + subject: string; + }; + /** @description Acknowledgement that a template was deleted. */ + TemplateV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; + }; + /** @description Cursor-paginated list of templates. */ + TemplateV1List: { + data: components["schemas"]["TemplateV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; /** @description Body for PATCH /api/v1/templates/{id}. */ TemplateV1Update: { - name?: string; - description?: string | null; - subject?: string; body?: string; + description?: string | null; + /** @enum {string} */ + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; /** Format: email */ from?: string; from_name?: string | null; + name?: string; /** Format: email */ reply_to?: string | null; - /** @enum {string} */ - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; - }; - /** @description Acknowledgement that a template was deleted. */ - TemplateV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; - }; - /** @description Body for POST /api/snippets. */ - CreateSnippet: { - name: string; - description?: string | null; - body: string; + subject?: string; }; - /** @description Body for PATCH /api/snippets/{id}. */ - UpdateSnippet: { - name?: string; + TopicCreateV1: { + default_opt_in?: boolean; description?: string | null; - body?: string; - }; - /** @description Body for POST /api/webhooks — register a user webhook for one or more events. */ - CreateWebhook: { - /** Format: uri */ - url: string; - eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - }; - /** @description Body for PATCH /api/webhooks/{id}. */ - UpdateWebhook: { - /** Format: uri */ - url?: string; - eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** @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. */ + key: string; + name: string; }; - /** @description Cursor-paginated list of webhook endpoints. */ - WebhookV1List: { - data: components["schemas"]["WebhookV1"][]; + /** @description One page of the subjects this project mails about. */ + TopicListV1: { + data: components["schemas"]["TopicV1"][]; has_more: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; - /** @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. */ - WebhookV1: { - /** Format: uuid */ - id: string; - url: string; - event_types: string[]; - /** @enum {string} */ - status: "ACTIVE" | "PAUSED" | "DISABLED"; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - }; - /** @description A newly created webhook and its one-time signing secret. */ - WebhookV1Created: { - webhook: components["schemas"]["WebhookV1"]; - /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ - secret: string; - }; - /** @description Body for POST /api/v1/webhooks. */ - WebhookV1Create: { - /** Format: uri */ - url: string; - event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - }; - /** @description Body for PATCH /api/v1/webhooks/{id}. */ - WebhookV1Update: { - /** Format: uri */ - url?: string; - event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; - /** @enum {string} */ - status?: "ACTIVE" | "PAUSED" | "DISABLED"; - }; - /** @description Acknowledgement that a webhook was deleted. */ - WebhookV1Deleted: { - /** Format: uuid */ - id: string; - /** @enum {boolean} */ - deleted: true; - }; - /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ - WebhookV1SecretRotated: { - /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ - secret: string; - /** - * Format: date-time - * @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. - */ - previous_secret_expires_at: string; - }; - CreateApiKeyBody: { - name: string; - /** @enum {string} */ - legacyGrantPreset?: "FULL" | "SENDING_ONLY"; - /** - * @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 {string} - */ - mode?: "LIVE" | "TEST"; - /** @description The explicit grant the new key will carry. Omitted ⇒ materialised from `legacyGrantPreset`. A `SENDING_ONLY` key may carry only `emails:send`. */ - scopes?: ("emails:send" | "emails:read" | "contacts:read" | "contacts:write" | "campaigns:read" | "campaigns:write" | "segments:read" | "segments:write" | "workflows:read" | "workflows:write" | "templates:read" | "templates:write" | "domains:read" | "domains:write" | "webhooks:read" | "webhooks:write" | "suppression:read" | "suppression:write" | "analytics:read" | "usage:read" | "events:read" | "events:write" | "projects:read" | "projects:write" | "api-keys:read" | "api-keys:write" | "campaigns:send" | "mailboxes:read" | "mailboxes:write" | "emails:test" | "deliverability:read" | "mailboxes:send" | "validation:read" | "validation:write" | "topics:read" | "topics:write" | "lists:read" | "lists:write")[]; + TopicSubscribeV1: { /** Format: uuid */ - domainId?: string | null; + contact_id: string; + /** @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. */ + subscribed: boolean; }; - /** @description Body for POST /api/suppression — manually add an email to the suppression list. */ - AddSuppression: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** + * @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 {string} + */ + TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; + TopicSubscriptionV1: { + /** @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. */ + confirmation_url: string | null; + /** Format: date-time */ + confirmed_at: string | null; + contact_id: string; + status: components["schemas"]["TopicSubscriptionStatusV1"]; + topic_id: string; }; - /** @description Cursor-paginated list of suppressed addresses. */ - SuppressionV1List: { - data: components["schemas"]["SuppressionV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ + TopicUpdateV1: { + archived?: boolean; + default_opt_in?: boolean; + description?: string | null; + name?: string; }; - /** @description A suppressed address as exposed on the v1 API. */ - SuppressionV1: { - email: string; - /** @enum {string} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - source: string; + /** @description One subject this project mails about. */ + TopicV1: { + /** @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. */ + archived: boolean; /** Format: date-time */ created_at: string; - }; - /** @description Body for POST /api/v1/suppressions. */ - SuppressionV1Create: { - /** Format: email */ - email: string; - /** - * @default MANUAL - * @enum {string} - */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; - /** @description Acknowledgement that an address was un-suppressed. */ - SuppressionV1Deleted: { - email: string; - /** @enum {boolean} */ - deleted: true; + /** @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`. */ + default_opt_in: boolean; + description: string | null; + id: string; + /** @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. */ + key: string; + name: string; + /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ + subscribed_count: number; + unsubscribed_count: number; }; /** @description Body for POST /api/track — record a custom event for a contact. */ TrackEvent: { - event: string; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + data?: { + [key: string]: unknown; + }; /** Format: email */ email: string; + event: string; subscribed?: boolean; - /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ - data?: { + }; + /** @description Response from POST /api/track. */ + TrackEventResponse: { + data: { + /** Format: uuid */ + contact: string; + /** Format: uuid */ + event: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + timestamp: string; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description Body for PATCH /api/contacts/{id}. `email` is immutable here — use upsert to change addresses. */ + UpdateContactBody: { + customFields?: { [key: string]: unknown; }; + subscribed?: boolean; }; - /** @description Cursor-paginated list of events, newest first. */ - EventV1List: { - data: components["schemas"]["EventV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + /** @description Body for PATCH /api/snippets/{id}. */ + UpdateSnippet: { + body?: string; + description?: string | null; + name?: string; }; - /** @description A recorded custom event. */ - EventV1: { + /** @description Body for PATCH /api/templates/{id}. */ + UpdateTemplate: { + body?: string; + description?: string; + /** @enum {string} */ + emailCategory?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + /** Format: email */ + from?: string; + fromName?: string | null; + name?: string; + /** Format: email */ + replyTo?: string | null; + subject?: string; + }; + /** @description Body for PATCH /api/webhooks/{id}. */ + UpdateWebhook: { + eventTypes?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: uri */ + url?: string; + }; + /** @description Current email usage against the limits that are actually enforced. */ + UsageV1: { + daily: { + /** @description Today's sends. Null when the counter could not be read. */ + emails_sent: number | null; + limit: number; + /** @enum {string} */ + trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + }; + monthly: { + categories: { + campaign: { + emails_sent: number; + limit: number | null; + }; + inbound: { + emails_sent: number; + limit: number | null; + }; + transactional: { + emails_sent: number; + limit: number | null; + }; + workflow: { + emails_sent: number; + limit: number | null; + }; + }; + emails_sent: number; + /** @description Monthly cap on the total. Null when per-category limits govern instead. */ + limit: number | null; + }; + /** + * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. + * @enum {string} + */ + plan: "free" | "pro" | "custom"; + }; + /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ + VerifyEmail: { + /** Format: email */ + email: string; + }; + /** @description Response from POST /api/verify — outcome of the syntax/MX/disposable check. */ + VerifyEmailResponse: { + data: { + /** Format: email */ + email: string; + reason?: string; + valid: boolean; + } & { + [key: string]: unknown; + }; + /** @enum {boolean} */ + success: true; + }; + /** @description A user-managed outbound webhook. */ + Webhook: { + consecutiveFailures: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + disabledAt?: string | null; + eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; /** Format: uuid */ id: string; - name: string; + lastFour?: string; /** Format: uuid */ - contact_id: string | null; + projectId: string; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + updatedAt: string; + /** Format: uri */ + url: string; + }; + /** @description An attempted webhook delivery. */ + WebhookCall: { + attempt: number; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + createdAt: string; + eventType: string; /** Format: uuid */ - email_id: string | null; - /** @description The payload recorded with the event, or null. */ + id: string; payload: { [key: string]: unknown; - } | null; - /** Format: date-time */ - created_at: string; + }; + responseBody?: string | null; + responseStatus?: number | null; + /** @enum {string} */ + status: "PENDING" | "SUCCESS" | "FAILED"; + /** Format: uuid */ + webhookId: string; }; - /** @description Body for POST /api/v1/events. */ - EventTrackV1: { - /** @description Event name, e.g. `user.signup`. */ - name: string; - /** - * Format: uuid - * @description Contact the event belongs to. Must already exist in this project — unlike the legacy `POST /api/track`, this endpoint never creates contacts. Omit for a project-level event. - */ - contact_id?: string; - /** @description Arbitrary event payload. */ - payload?: { - [key: string]: string | number | boolean | { - [key: string]: unknown; - } | unknown[] | null; + /** @description Cursor-paginated list of recent calls for a single webhook. */ + WebhookCallsListResponse: { + cursor?: string | null; + data: components["schemas"]["WebhookCall"][]; + hasMore?: boolean; + nextCursor?: string | null; + /** @enum {boolean} */ + success: true; + }; + /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ + WebhookCreateResponse: { + /** @description A user-managed outbound webhook. */ + data: components["schemas"]["Webhook"] & { + /** @description Plaintext shared secret. Returned ONCE on create. */ + secret: string; }; + /** @enum {boolean} */ + success: true; }; - /** @description Every distinct event name in the project, most frequent first. */ - EventNamesV1: { - data: string[]; + /** @description Single webhook (no secret). */ + WebhookGetResponse: { + data: components["schemas"]["Webhook"]; + /** @enum {boolean} */ + success: true; }; - /** @description Per-name event counts over the applied window. */ - EventStatsV1: { + /** @description List of webhooks for the auth'd project. */ + WebhookListResponse: { + data: components["schemas"]["Webhook"][]; + /** @enum {boolean} */ + success: true; + }; + /** @description Response from POST /api/webhooks/{id}/rotate-secret — returns the new plaintext once. */ + WebhookRotateSecretResponse: { data: { - name: string; - count: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** Format: uuid */ + id: string; + /** @description New plaintext shared secret. */ + secret: string; + }; + /** @enum {boolean} */ + success: true; }; - /** @description The time range this response was computed over, after the 90-day clamp. */ - AnalyticsWindowV1: { + /** @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. */ + WebhookV1: { /** Format: date-time */ - from: string; + created_at: string; + event_types: string[]; + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "ACTIVE" | "PAUSED" | "DISABLED"; /** Format: date-time */ - to: string; + updated_at: string; + url: string; }; - /** @description Daily email counters across the window. Every day in range is present, zero-filled. */ - AnalyticsTimeseriesV1: { - data: { - /** Format: date-time */ - date: string; - emails: number; - delivered: number; - opens: number; - clicks: number; - bounces: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description Body for POST /api/v1/webhooks. */ + WebhookV1Create: { + event_types: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** Format: uri */ + url: string; }; - /** @description Campaign counters and engagement over the window. */ - AnalyticsCampaignStatsV1: { - total: number; - /** @description Campaigns in DRAFT or SCHEDULED. */ - active: number; - completed: number; - /** @description Percentage, one decimal place. */ - average_open_rate: number; - average_click_rate: number; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description A newly created webhook and its one-time signing secret. */ + WebhookV1Created: { + /** @description The signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + webhook: components["schemas"]["WebhookV1"]; }; - /** @description Sent campaigns ranked by open rate. */ - AnalyticsTopCampaignsV1: { - data: { - /** Format: uuid */ - id: string; - subject: string; - sent: number; - opened: number; - clicked: number; - open_rate: number; - click_rate: number; - }[]; - window: components["schemas"]["AnalyticsWindowV1"]; + /** @description Acknowledgement that a webhook was deleted. */ + WebhookV1Deleted: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ + id: string; }; - /** @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. */ - DeliverabilityDiagnosisV1: { - domain: string; - address: string | null; - /** Format: date-time */ - checked_at: string; - identity: components["schemas"]["DeliverabilityIdentityV1"]; - suppression: components["schemas"]["DeliverabilitySuppressionV1"]; - recent_delivery: components["schemas"]["DeliverabilityRecentDeliveryV1"]; - /** @description What is wrong, worst first. An empty array means nothing here explains a delivery problem. */ - findings: components["schemas"]["DeliverabilityFindingV1"][]; + /** @description Cursor-paginated list of webhook endpoints. */ + WebhookV1List: { + data: components["schemas"]["WebhookV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; }; - /** @description The sending identity's DNS health, as last refreshed. */ - DeliverabilityIdentityV1: { - /** @description Whether this project has a domain record at all. False makes every other field null. */ - registered: boolean; - verified: boolean; + /** @description A freshly rotated signing secret, and the moment the outgoing one stops verifying. */ + WebhookV1SecretRotated: { /** - * @description DKIM signing. This is the one that decides whether Sendly will send from the domain at all. - * @enum {string|null} + * Format: date-time + * @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. */ - dkim_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + previous_secret_expires_at: string; + /** @description The new signing secret, shown EXACTLY ONCE. Store it now — no endpoint returns it again. */ + secret: string; + }; + /** @description Body for PATCH /api/v1/webhooks/{id}. */ + WebhookV1Update: { + event_types?: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; + /** @enum {string} */ + status?: "ACTIVE" | "PAUSED" | "DISABLED"; + /** Format: uri */ + url?: string; + }; + /** @description Body for `POST /api/v1/workflows/{id}/clone`. */ + WorkflowCloneV1: { + /** @description Name for the copy. Defaults to `Copy of `. */ + name?: string; + }; + /** @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. */ + WorkflowConditionStepV1: { + config: { + branches?: ({ + id: string; + name: string; + /** @enum {string} */ + operator: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + })[]; + field?: string; + /** @enum {string} */ + mode?: "multi"; + /** @enum {string} */ + operator?: "equals" | "notEquals" | "contains" | "notContains" | "greaterThan" | "lessThan" | "greaterThanOrEqual" | "lessThanOrEqual" | "exists" | "notExists"; + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + value?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * @description SPF alignment for the sending identity. - * @enum {string|null} + * Format: uuid + * @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. */ - spf_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * @description The DMARC policy published at `_dmarc.`. - * @enum {string|null} + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - dmarc_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + template_id?: string | null; /** - * @description Inbound receiving only. Null unless the domain has receiving enabled. - * @enum {string|null} + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - mx_status: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; - mail_from_domain: string | null; - /** @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. */ - mail_from_domain_status: string | null; + type: "CONDITION"; + }; + /** @description Body for POST /api/v1/workflows. */ + WorkflowCreateV1: { + allow_reentry?: boolean; + description?: string; + /** @description Workflows are created disabled. A workflow can only be enabled once every step is configured. */ + enabled?: boolean; + /** @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. */ + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + name: string; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"]; + }; + /** @description Pauses the run for `amount` × `unit`, up to 365 days. */ + WorkflowDelayStepV1: { + config: { + amount?: number; + /** @enum {string} */ + unit?: "minutes" | "hours" | "days"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * Format: date-time - * @description When the DNS refresh job last looked. These statuses are a CACHE, not a live lookup. + * Format: uuid + * @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. */ - last_checked_at: string | null; - }; - /** @description Null unless the request named an `address`. */ - DeliverabilitySuppressionV1: { - suppressed: boolean; - /** @enum {string|null} */ - reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE" | null; - /** @enum {string|null} */ - source: "SES_WEBHOOK" | "API" | "DASHBOARD" | null; - /** Format: date-time */ - suppressed_at: string | null; - } | null; - /** @description Delivery outcomes over the requested window. */ - DeliverabilityRecentDeliveryV1: { - window_days: number; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * @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 {string} + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - scope: "project"; - sent: number; - delivered: number; - bounced: number; - complained: number; - failed: number; - /** @description Bounced ÷ sent (0–1), or null when nothing was sent in the window. */ - bounce_rate: number | null; - complaint_rate: number | null; - }; - /** @description One diagnosed problem, with its fix. */ - DeliverabilityFindingV1: { - /** @description Stable identifier for this finding, e.g. `domain_not_verified`. Branch on this, not on `summary`. */ - code: string; - severity: components["schemas"]["DeliverabilityFindingSeverityV1"]; - /** @description What is wrong, in one sentence. */ - summary: string; - /** @description What to do about it. */ - remedy: string; - }; - /** - * @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 {string} - */ - DeliverabilityFindingSeverityV1: "blocking" | "degraded" | "info"; - /** @description Cursor-paginated recipient-domain rollup, newest day first. */ - RecipientDomainStatsV1List: { - data: components["schemas"]["RecipientDomainStatsV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; - }; - /** @description Delivery outcomes for one recipient domain on one day. */ - RecipientDomainStatsV1: { - /** @description The recipient's domain, lowercased: the part after the `@`. */ - domain: string; - /** @description The UTC day these counts cover, as `YYYY-MM-DD`. */ - day: string; - sent: number; - delivered: number; - bounced: number; - complained: number; - opened: number; + template_id?: string | null; /** - * Format: date-time - * @description When the rollup job last rebuilt this row. These counts are a CACHE, refreshed hourly. + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - computed_at: string; - }; - /** @description Cursor-paginated DMARC aggregate reports, newest window first. */ - DmarcReportV1List: { - data: components["schemas"]["DmarcReportV1"][]; - has_more: boolean; - /** @description Pass as `after` to fetch the next page. `null` on the last page. */ - next_cursor: string | null; + type: "DELAY"; }; - /** @description One DMARC aggregate (RUA) report. */ - DmarcReportV1: { + /** @description Confirmation that a workflow was deleted. */ + WorkflowDeletedV1: { + /** @enum {boolean} */ + deleted: true; + /** Format: uuid */ id: string; - /** @description The receiver's own id for this report. */ - report_id: string; - /** @description The reporting receiver, e.g. `google.com`. */ - org_name: string; - /** @description The domain of yours the report is about. */ - policy_domain: string; - /** Format: date-time */ - range_begin: string; - /** Format: date-time */ - range_end: string; - total_count: number; - /** @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. */ - pass_count: number; - fail_count: number; - /** @description Per-sending-source rows, as the receiver reported them. */ - sources: { - source_ip: string; - count: number; - disposition: string; - dkim: string; - spf: string; - header_from: string; - }[]; - /** Format: date-time */ - received_at: string; }; - /** @description Current email usage against the limits that are actually enforced. */ - UsageV1: { + /** @description Body for POST /api/v1/workflows/{id}/executions. */ + WorkflowExecutionStartV1: { /** - * @description `custom` when an operator set per-category limits, `pro` on an active subscription or store entitlement, else `free`. - * @enum {string} + * Format: uuid + * @description Contact to enter the workflow. Must belong to this project. */ - plan: "free" | "pro" | "custom"; - monthly: { - emails_sent: number; - /** @description Monthly cap on the total. Null when per-category limits govern instead. */ - limit: number | null; - categories: { - transactional: { - emails_sent: number; - limit: number | null; - }; - campaign: { - emails_sent: number; - limit: number | null; - }; - workflow: { - emails_sent: number; - limit: number | null; - }; - inbound: { - emails_sent: number; - limit: number | null; - }; - }; - }; - daily: { - /** @description Today's sends. Null when the counter could not be read. */ - emails_sent: number | null; - limit: number; - /** @enum {string} */ - trust_tier: "NEW" | "ESTABLISHED" | "TRUSTED"; + contact_id: string; + /** @description Extra variables merged into the contact's data for this run. */ + context?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; }; - /** @description The project the presented credential is scoped to. */ - ProjectV1: { + /** @description One contact's run through a workflow. */ + WorkflowExecutionV1: { + /** Format: date-time */ + completed_at: string | null; + /** Format: uuid */ + contact_id: string; + /** Format: uuid */ + current_step_id: string | null; + exit_reason: string | null; /** Format: uuid */ id: string; - name: string; - /** @description A disabled project sends nothing; every send is refused. */ - disabled: boolean; - /** @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. */ - sandbox_address: string | null; - /** @description Locked once the first domain is added. */ - ses_region: string | null; - /** @enum {string} */ - tracking: "ENABLED" | "DISABLED" | "MARKETING_ONLY"; - /** @description ISO 639-1 code for customer-facing content. */ - language: string; /** Format: date-time */ - created_at: string; - }; - /** @description A receiving mailbox on one of the project's verified domains. */ - Mailbox: { + started_at: string; + /** @enum {string} */ + status: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; /** Format: uuid */ + workflow_id: string; + }; + /** @description Cursor-paginated list of workflow executions, newest first. */ + WorkflowExecutionV1List: { + data: components["schemas"]["WorkflowExecutionV1"][]; + has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Ends the run early and stamps `exit_reason`. */ + WorkflowExitStepV1: { + config: { + reason?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: email - * @description The full mailbox address, e.g. `support@superbooks.io`. + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - address: string; - displayName: string | null; + template_id?: string | null; /** - * @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 discriminator enum property added by openapi-typescript * @enum {string} */ - status: "PROVISIONING" | "ACTIVE" | "SUSPENDED" | "FAILED"; - /** @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. */ - quotaBytes: number | null; + type: "EXIT"; + }; + /** @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. */ + WorkflowGraphReplaceV1: { + /** @description The complete step set. Exactly one must be a `TRIGGER`. */ + steps: components["schemas"]["WorkflowStepV1"][]; + /** @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. */ + transitions: components["schemas"]["WorkflowTransitionV1"][]; + }; + /** @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. */ + WorkflowGraphV1: { + steps: components["schemas"]["WorkflowStepReadV1"][]; + transitions: components["schemas"]["WorkflowTransitionV1"][]; + /** @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. */ + version: number; + /** Format: uuid */ + workflow_id: string; + }; + /** @description Like `SEND_EMAIL`, but held until this contact's historically best open hour, falling back to `fallbackHour` and never waiting longer than `maxDelayHours`. */ + WorkflowSendAtOptimalTimeStepV1: { + config: { + fallbackHour?: number; + maxDelayHours?: number; + /** Format: uuid */ + templateId?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** * Format: uuid - * @description The verified domain this mailbox lives on. + * @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. */ - domainId: string; - /** Format: date-time */ - createdAt: string; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_AT_OPTIMAL_TIME"; }; - /** @description A mailbox plus its IMAP/SMTP connection settings. */ - MailboxDetail: components["schemas"]["Mailbox"] & { - /** @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. */ - settings: { - imap: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; - }; - smtp: { - host: string; - port: number; - /** @description Transport security, e.g. `SSL/TLS`. */ - security: string; - /** @description The mailbox address — it is also the login. */ - username: string; + /** @description Sends one email to the contact. Give it either `template_id` (preferred) or an inline `subject` + `body`. */ + WorkflowSendEmailStepV1: { + config: { + body?: string; + recipient?: { + /** Format: email */ + customEmail?: string; + /** @enum {string} */ + type: "CONTACT" | "CUSTOM"; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; + subject?: string; + /** Format: uuid */ + templateId?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; - }; - CreateMailboxBody: { /** * Format: uuid - * @description Defaults to the project the credential resolves to. Naming a different one is refused. + * @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. */ - projectId?: string; + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** * Format: uuid - * @description A VERIFIED domain belonging to this project. + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - domainId: string; - /** @description The part before the `@`, e.g. `support`. Lowercased server-side. */ - localPart: string; - displayName?: string; - /** @description NOT IMPLEMENTED — sending any value answers 400. */ - quotaBytes?: number; + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SEND_EMAIL"; }; - /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ - DraftMailboxMessage: { - /** @enum {string} */ - mode: "draft" | "rewrite" | "subject"; - brief?: string; - draft?: string; - instruction?: string; - /** @enum {string} */ - tone?: "friendly" | "neutral" | "formal" | "apologetic" | "direct"; - recipientContext?: string; - senderAddress?: string; + /** + * @description A step kind that may appear in a linear `sequence`. `TRIGGER` is prepended by the server. + * @enum {string} + */ + WorkflowSequenceStepTypeV1: "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; + /** @description One step of a linear workflow sequence. */ + WorkflowSequenceStepV1: { + /** @description Step configuration. Keys are camelCase — see `WorkflowStepV1` for the shape each step type expects. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** @description Human-readable label, e.g. `Day 0: welcome`. */ + name: string; + /** + * Format: uuid + * @description For `SEND_EMAIL`: a template in this project. + */ + template_id?: string; + type: components["schemas"]["WorkflowSequenceStepTypeV1"]; }; - /** @description Body for POST /api/mailboxes/{id}/messages — a new outbound message from a hosted mailbox. */ - ComposeMailboxMessage: { - to: string[]; - cc?: string[]; - bcc?: string[]; - subject: string; - body: string; + /** @description The workflow after a pause or resume, with the number of runs the call stopped. */ + WorkflowStateChangeV1: { + /** @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). */ + cancelled_executions: number; + workflow: components["schemas"]["WorkflowV1"]; + }; + /** @description Execution, email and conversion totals for one workflow. */ + WorkflowStatsV1: { + avg_duration_ms: number | null; + /** @description Execution counts keyed by status; a status with no executions is absent. */ + by_status: { + [key: string]: number; + }; + /** @description Completed ÷ finished executions (0–1). Null until at least one execution has finished. */ + completion_rate: number | null; + conversions: { + count: number; + event_name: string; + /** Format: uuid */ + goal_id: string; + name: string; + }[]; + emails: { + clicked: number; + opened: number; + sent: number; + }; + enabled: boolean; + name: string; + /** @description Steps in the workflow's graph, trigger step included. */ + step_count: number; + total: number; + trigger_type: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + /** Format: uuid */ + workflow_id: string; + }; + /** @description Where this step sits on the editor canvas. */ + WorkflowStepPositionV1: { + x: number; + y: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; }; - /** @description An IMAP/SMTP credential for one mailbox, described but never reproduced. */ - AppPassword: { - /** Format: uuid */ + /** @description One node of a workflow graph, as read. */ + WorkflowStepReadV1: { + /** @description The step's configuration, exactly as stored. See `WorkflowStepV1` for the keys each step type uses; keys are camelCase. */ + config: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; - /** @description What the credential is for, e.g. `Thunderbird on my laptop`. */ name: string; - /** @description Which protocols this password may authenticate. `imap` reads, `smtp` sends. */ - scopes: ("imap" | "smtp")[]; - /** @description The last four characters of the secret — enough to tell two credentials apart, and nothing more. */ - lastFour: string; + position: components["schemas"]["WorkflowStepPositionV1"]; /** - * Format: date-time - * @description Null until a mail client has authenticated with it at least once. + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. */ - lastUsedAt: string | null; - /** Format: date-time */ - createdAt: string; + template_id?: string | null; + /** @enum {string} */ + type: "TRIGGER" | "SEND_EMAIL" | "DELAY" | "WAIT_FOR_EVENT" | "CONDITION" | "EXIT" | "WEBHOOK" | "UPDATE_CONTACT" | "SEND_AT_OPTIMAL_TIME"; }; - /** @description A newly created app password, handed over as a one-time link rather than as a secret. */ - AppPasswordReveal: { + /** @description One node of a workflow graph. */ + WorkflowStepV1: components["schemas"]["WorkflowTriggerStepV1"] | components["schemas"]["WorkflowSendEmailStepV1"] | components["schemas"]["WorkflowDelayStepV1"] | components["schemas"]["WorkflowWaitForEventStepV1"] | components["schemas"]["WorkflowConditionStepV1"] | components["schemas"]["WorkflowExitStepV1"] | components["schemas"]["WorkflowWebhookStepV1"] | components["schemas"]["WorkflowUpdateContactStepV1"] | components["schemas"]["WorkflowSendAtOptimalTimeStepV1"]; + /** @description One directed edge between two steps. */ + WorkflowTransitionV1: { + /** @description Null to always follow this edge. From a `CONDITION` step, `{ "branch": "yes" }`, `{ "branch": "no" }`, or `{ "branch": "" }` in the multi form. */ + condition: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; /** Format: uuid */ - id: string; - /** - * Format: uri - * @description A single-use link that shows the password once, in a browser. Opening it requires a signed-in Sendly session belonging to a project admin — the connection that created the password cannot open it, and the second attempt to open it fails whoever makes it. - */ - revealUrl: string; + from_step_id: string; /** - * Format: date-time - * @description When the link stops working. Five minutes after creation; the password itself does not expire. + * Format: uuid + * @description Caller-chosen on a write, exactly like a step id. */ - revealExpiresAt: string; + id: string; + /** @description Evaluation order among the edges leaving one step; lowest first. */ + priority: number; + /** Format: uuid */ + to_step_id: string; }; - /** @description Body for POST /api/mailboxes/:id/app-passwords. */ - CreateAppPassword: { - name: string; + /** @description The graph's single entry node. Its config mirrors the workflow's own trigger: `eventName` for `EVENT`, `intervalMs` for `SCHEDULE`, empty for `MANUAL`. */ + WorkflowTriggerStepV1: { + config: { + eventName?: string; + intervalMs?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; /** - * @default [ - * "imap", - * "smtp" - * ] + * Format: uuid + * @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. */ - scopes: ("imap" | "smtp")[]; - }; - /** @description Body for POST /api/verify — validate email syntax, MX, disposable, etc. */ - VerifyEmail: { - /** Format: email */ - email: string; - }; - TopicListV1: { - data: components["schemas"]["TopicV1"][]; - cursor: string | null; - has_more: boolean; - }; - /** @description One subject this project mails about. */ - TopicV1: { id: string; - /** @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. */ - key: string; - name: string; - description: string | null; - /** @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`. */ - default_opt_in: boolean; - /** @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. */ - archived: boolean; - /** @description Contacts who explicitly said yes. Excludes those covered only by `default_opt_in`. */ - subscribed_count: number; - unsubscribed_count: number; - /** Format: date-time */ - created_at: string; - }; - TopicCreateV1: { - /** @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. */ - key: string; name: string; - description?: string | null; - default_opt_in?: boolean; - }; - /** @description `key` is deliberately absent. It is the name every stored preference and every integration refers to, so changing it would silently orphan them. */ - TopicUpdateV1: { - name?: string; - description?: string | null; - default_opt_in?: boolean; - archived?: boolean; - }; - TopicSubscriptionV1: { - topic_id: string; - contact_id: string; - status: components["schemas"]["TopicSubscriptionStatusV1"]; - /** Format: date-time */ - confirmed_at: string | null; - /** @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. */ - confirmation_url: string | null; - }; - /** - * @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 {string} - */ - TopicSubscriptionStatusV1: "pending" | "subscribed" | "unsubscribed"; - TopicSubscribeV1: { - /** Format: uuid */ - contact_id: string; - /** @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. */ - subscribed: boolean; - }; - /** @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. */ - ContactTopicPreferencesV1: { - contact_id: string; - /** @description The global marketing opt-out, which OUTRANKS every topic. False means no marketing reaches this contact whatever the topics below say. */ - subscribed: boolean; - topics: { - topic_id: string; - /** @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. */ - key: string; - name: string; - /** @description The EFFECTIVE answer: what the send path concludes for this contact today. */ - subscribed: boolean; - pending: boolean; - }[]; - }; - /** @description One verdict per address, in the order they were given. */ - EmailValidationBatchV1: { - results: components["schemas"]["EmailValidationV1"][]; - }; - /** @description One address's verdict, with the evidence behind it. */ - EmailValidationV1: { - email: string; - verdict: components["schemas"]["EmailValidationVerdictV1"]; - /** @description A throwaway-inbox provider. The ONLY flag here that lowers the verdict. */ - is_disposable: boolean; - /** @description The local part addresses a role (`support@`, `info@`), not a person. List-quality information: role mailboxes are deliverable and companies answer them. */ - is_role_address: boolean; - /** @description A free/consumer provider (Gmail, Outlook). List-quality information, not a problem. */ - is_personal: boolean; - /** @description The domain publishes MX records. */ - has_mx_records: boolean; - /** @description Human-readable findings. Prose for a person to read — branch on `verdict`, never on these. */ - reasons: string[]; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "TRIGGER"; }; /** - * @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`. + * @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 {string} */ - EmailValidationVerdictV1: "deliverable" | "undeliverable" | "risky" | "unknown"; - EmailValidationBatchRequestV1: { - /** @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. */ - emails: string[]; - }; - /** @description One bulk validation run over a list. */ - EmailValidationRunV1: { + WorkflowTriggerTypeV1: "EVENT" | "MANUAL" | "SCHEDULE"; + /** @description Writes `updates` onto the contact, and optionally flips `subscribed`. */ + WorkflowUpdateContactStepV1: { + config: { + subscribed?: boolean; + updates?: { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ id: string; - list_id: string | null; - /** @enum {string} */ - status: "pending" | "running" | "completed" | "failed"; - /** @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. */ - processed_count: number; - deliverable_count: number; - undeliverable_count: number; - risky_count: number; - /** Format: date-time */ - started_at: string | null; - /** Format: date-time */ - completed_at: string | null; - /** @description Set only on `failed`. Prose for an operator; never parse it. */ - failure_reason: string | null; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "UPDATE_CONTACT"; + }; + /** @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. */ + WorkflowUpdateV1: { + allow_reentry?: boolean; + description?: string; + enabled?: boolean; + event_name?: string; + /** @description For `SCHEDULE` workflows: how often the workflow fires, in milliseconds. Between one minute and 30 days; defaults to one hour. */ + interval_ms?: number; + /** @description Per-workflow start rate cap. `null` removes the cap. */ + max_executions_per_hour?: number | null; + name?: string; + /** @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. */ + sequence?: components["schemas"]["WorkflowSequenceStepV1"][]; + trigger_type?: components["schemas"]["WorkflowTriggerTypeV1"] & unknown; + }; + /** @description An automation workflow as exposed on the v1 API. */ + WorkflowV1: { + allow_reentry: boolean; /** Format: date-time */ created_at: string; + description: string | null; + enabled: boolean; + /** @description Trigger event for `EVENT` workflows; null for the other trigger types. */ + event_name: string | null; + /** Format: uuid */ + id: string; + max_executions_per_hour: number | null; + name: string; + /** @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. */ + step_count: number; + /** @enum {string} */ + trigger_type: "EVENT" | "MANUAL" | "SCHEDULE"; + /** Format: date-time */ + updated_at: string; + /** @description Incremented on every structural (step/transition) change. */ + version: number; }; - /** @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. */ - EmailValidationResultListV1: { - data: (components["schemas"]["EmailValidationV1"] & { - contact_id: string | null; - })[]; - /** @description Pass as `cursor` for the next page; null on the last. */ - cursor: string | null; + /** @description Cursor-paginated list of workflows. */ + WorkflowV1List: { + data: components["schemas"]["WorkflowV1"][]; has_more: boolean; + /** @description Pass as `after` to fetch the next page. `null` on the last page. */ + next_cursor: string | null; + }; + /** @description Parks the run until `eventName` is recorded for this contact, or `timeout` seconds pass. */ + WorkflowWaitForEventStepV1: { + config: { + eventName?: string; + timeout?: number; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WAIT_FOR_EVENT"; + }; + /** @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. */ + WorkflowWebhookStepV1: { + config: { + /** @description Arbitrary JSON value (string, number, boolean, null, array, or object). */ + body?: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + headers?: { + [key: string]: string; + }; + /** @enum {string} */ + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Format: uri */ + url?: string; + } & { + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | unknown[] | null; + }; + /** + * Format: uuid + * @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. + */ + id: string; + name: string; + position: components["schemas"]["WorkflowStepPositionV1"]; + /** + * Format: uuid + * @description The `SEND_EMAIL`/`SEND_AT_OPTIMAL_TIME` template, as a relation. Null for every other step type. + */ + template_id?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "WEBHOOK"; }; }; responses: never; @@ -5238,12 +5255,13 @@ export interface components { } export type $defs = Record; export interface operations { - v1ListCampaigns: { + listContacts: { parameters: { query?: { limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + cursor?: string; + search?: string; + subscribed?: "true" | "false"; }; header?: never; path?: never; @@ -5251,2822 +5269,2855 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Campaign list */ + /** @description Contact list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1List"]; + "application/json": components["schemas"]["ContactListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateCampaign: { + createContact: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Create"]; + "application/json": components["schemas"]["CreateContact"]; }; }; responses: { - /** @description Campaign created */ + /** @description Contact created */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": 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. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `internal_error`. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - }; - }; - v1GetCampaign: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The campaign */ - 200: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Email already exists for this project */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteCampaign: { + bulkCreateContacts: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactBulkCreateBody"]; + }; + }; responses: { - /** @description Campaign deleted */ + /** @description Bulk-create result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Deleted"]; + "application/json": { + data: { + created: number; + errors: { + index: number; + message: string; + }[]; + skipped: number; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateCampaign: { + bulkDeleteContacts: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Update"]; + "application/json": components["schemas"]["ContactBulkDeleteBody"]; }; }; responses: { - /** @description The updated campaign */ + /** @description Bulk-delete result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: { + deleted: number; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendCampaign: { + upsertContact: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; - }; - path: { - /** @description Resource id. */ - id: string; - }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["CampaignV1Send"]; + "application/json": components["schemas"]["CreateContact"]; }; }; responses: { - /** @description The campaign, now `SENDING` or `SCHEDULED` */ + /** @description Contact created or updated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — the campaign has already been sent or is sending, has no recipients, or `scheduled_for` is not in the future. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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 failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelCampaign: { + getContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The cancelled campaign */ + /** @description Contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1PauseCampaign: { + deleteContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The paused campaign */ + /** @description Contact deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": components["schemas"]["IdResponse"]; }; }; - /** @description `validation_error` — only a `SENDING` campaign can be paused. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ResumeCampaign: { + updateContact: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateContactBody"]; + }; + }; responses: { - /** @description The resumed campaign */ + /** @description Updated contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1"]; + "application/json": { + data: components["schemas"]["Contact"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetCampaignStats: { + listDomains: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description Domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1Stats"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["DomainListResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListCampaignFailures: { + addDomain: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AddDomainBody"]; + }; + }; responses: { - /** @description Failed sends */ - 200: { + /** @description Domain added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1FailureList"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @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. */ + 502: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1RetryCampaignFailures: { + getDomain: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The retry was queued */ + /** @description Domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CampaignV1RetryFailed"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — a retry is already running for this campaign. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegments: { + deleteDomain: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment list */ + /** @description Domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1List"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateSegment: { + assignDomainStream: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SegmentV1Create"]; + "application/json": components["schemas"]["AssignDomainStream"]; }; }; responses: { - /** @description Segment created */ - 201: { + /** @description Updated sending identity */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: components["schemas"]["Domain"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetSegment: { + startDomainSetup: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The segment */ + /** @description Guided setup session */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: { + /** + * Format: uri + * @description Open this in a browser to publish the records. Short-lived and domain-specific. + */ + connectUrl: string; + /** @description When `connectUrl` stops working. */ + expiresAt: string; + token: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteSegment: { + getDomainVerification: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment deleted */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1Deleted"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["DomainVerificationStatus"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the segment is still used by one or more active campaigns. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateSegment: { + verifyDomain: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SegmentV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated segment */ + /** @description Verification status */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentV1"]; + "application/json": { + data: components["schemas"]["DomainVerificationStatus"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListSegmentContacts: { + listEmails: { parameters: { query?: { limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + cursor?: string; + tag?: string; + /** @description Delivery lifecycle of the message. Engagement is reported separately. */ + status?: components["schemas"]["EmailDeliveryStatus"]; + from?: string; }; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Segment member list */ + /** @description Email list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SegmentContactV1List"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["EmailListResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflows: { + sendEmail: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; }; - header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmail"]; + }; + }; responses: { - /** @description Workflow list */ + /** @description Email accepted / sent */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1List"]; + "application/json": components["schemas"]["SendEmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateWorkflow: { + sendEmailBatch: { parameters: { query?: never; - header?: never; + header?: { + "Idempotency-Key"?: string; + }; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowCreateV1"]; + "application/json": components["schemas"]["BatchSendBody"]; }; }; responses: { - /** @description Workflow created */ - 201: { + /** @description All entries sent */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["BatchSendResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Partial success — at least one entry failed */ + 207: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchSendResponse"]; + }; + }; + /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflow: { + getEmail: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow */ + /** @description Email and its delivery history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["EmailDetailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteWorkflow: { + cancelScheduledEmail: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow deleted */ + /** @description Email cancelled */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowDeletedV1"]; + "application/json": components["schemas"]["EmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow still has running executions. */ - 409: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Email already past PENDING */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateWorkflow: { + subscribeToList: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description List id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["WorkflowUpdateV1"]; + "application/json": components["schemas"]["ListSubscribe"]; }; }; responses: { - /** @description Updated workflow */ + /** @description Contact subscribed, or an existing membership returned unchanged */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": components["schemas"]["ListSubscribeResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the trigger cannot be changed while executions are running. */ + /** @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. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListWorkflowExecutions: { + unsubscribeFromList: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only executions in this state. */ - status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ + /** @description List id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ListUnsubscribe"]; + }; + }; responses: { - /** @description Execution list */ + /** @description Contact unsubscribed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1List"]; + "application/json": components["schemas"]["ListUnsubscribeResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1StartWorkflowExecution: { + listMailboxes: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WorkflowExecutionStartV1"]; - }; - }; + requestBody?: never; responses: { - /** @description Execution started */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Mailbox list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["Mailbox"][]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ - 404: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ - 409: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CancelWorkflowExecution: { + createMailbox: { parameters: { query?: never; header?: never; - path: { - /** @description Workflow execution id. */ - execution_id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMailboxBody"]; + }; + }; responses: { - /** @description Cancelled execution */ - 200: { + /** @description Mailbox provisioned */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowExecutionV1"]; + "application/json": { + data: components["schemas"]["Mailbox"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no execution with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflowStats: { + getMailbox: { parameters: { - query?: { - /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ - from?: string | null; - }; + query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Workflow statistics */ + /** @description Mailbox with connection settings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStatsV1"]; + "application/json": { + data: components["schemas"]["MailboxDetail"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetWorkflowGraph: { + deleteMailbox: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The workflow's graph */ + /** @description Mailbox deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowGraphV1"]; + "application/json": { + data: { + /** @enum {boolean} */ + deleted: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ReplaceWorkflowGraph: { + listAppPasswords: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; - }; - }; + requestBody?: never; responses: { - /** @description The graph as it now stands */ + /** @description App password list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowGraphV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": { + data: components["schemas"]["AppPassword"][]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CloneWorkflow: { + createAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["WorkflowCloneV1"]; + "application/json": components["schemas"]["CreateAppPassword"]; }; }; responses: { - /** @description The cloned workflow */ + /** @description App password created; the secret is behind the one-time link */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowV1"]; + "application/json": { + data: components["schemas"]["AppPasswordReveal"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1PauseWorkflow: { + revokeAppPassword: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; + passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The workflow, and the number of runs this call cancelled */ + /** @description App password revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStateChangeV1"]; + "application/json": { + data: { + /** @enum {boolean} */ + revoked: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ResumeWorkflow: { + draftMailboxMessage: { parameters: { query?: never; header?: never; path: { - /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DraftMailboxMessage"]; + }; + }; responses: { - /** @description The workflow, with `cancelled_executions` always 0 */ + /** @description A draft. Nothing was sent. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowStateChangeV1"]; + "application/json": { + data: { + /** @description Suggested plain-text body, or null. */ + body: string | null; + /** + * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. + * @enum {boolean} + */ + sent: false; + /** @description Suggested subject, or null. */ + subject: string | null; + /** @description Alternative subject lines (`subject` mode); empty otherwise. */ + subjects: string[]; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Rate limit or billing limit exceeded */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ - 429: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ - 500: { + /** @description The drafting model was unreachable or returned nothing usable. */ + 502: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendEmail: { + sendMailboxMessage: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; + header?: never; + path: { + id: string; }; - path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SendEmailV1"]; + "application/json": components["schemas"]["ComposeMailboxMessage"]; }; }; responses: { - /** @description Email queued */ - 202: { + /** @description Message submitted */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailV1"]; + "application/json": { + data: { + /** + * Format: uuid + * @description The conversation this send started. Replies thread onto it. + */ + conversationId: string; + /** + * Format: uuid + * @description The stored outbound message. + */ + messageId: string; + /** @enum {boolean} */ + submitted: true; + }; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ - 404: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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 The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": 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. */ + /** @description The mail server refused the submission. Nothing was sent. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ 503: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SendTestEmail: { + listApiKeys: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SendTestEmailV1"]; + path: { + /** @description Project id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Test email queued */ - 202: { + /** @description API key list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailTestV1"]; + "application/json": components["schemas"]["ApiKeyListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — the project's daily sandbox send cap is spent. It resets at 00:00 UTC. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - listEmails: { + createApiKey: { parameters: { - query?: { - limit?: number; - cursor?: string; - tag?: string; - /** @description Delivery lifecycle of the message. Engagement is reported separately. */ - status?: components["schemas"]["EmailDeliveryStatus"]; - from?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Project id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateApiKeyBody"]; + }; + }; responses: { - /** @description Email list */ - 200: { + /** @description API key created; the secret is behind the reveal link. */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailListResponse"]; + "application/json": { + /** @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. */ + data: components["schemas"]["ApiKey"] & { + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8116,32 +8167,30 @@ export interface operations { }; }; }; - sendEmail: { + revokeApiKey: { parameters: { query?: never; - header?: { - /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ - "Idempotency-Key"?: string; + header?: never; + path: { + /** @description Project id. */ + id: string; + /** @description API key id. */ + keyId: string; }; - path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SendEmail"]; - }; - }; + requestBody?: never; responses: { - /** @description Email accepted / sent */ + /** @description API key revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SendEmailResponse"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -8159,7 +8208,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -8168,17 +8217,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8204,35 +8244,45 @@ export interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - getEmail: { + rotateApiKey: { parameters: { query?: never; header?: never; path: { + /** @description Project id. */ id: string; + /** @description API key id. */ + keyId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Email and its delivery history */ + /** @description API key rotated; the new secret is behind the reveal link. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailDetailResponse"]; + "application/json": { + data: { + lastFour: string; + /** + * Format: date-time + * @description When the reveal link stops working. Create or rotate again to get a new one. + */ + revealExpiresAt: string; + /** + * Format: uri + * @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. + */ + revealUrl: string; + }; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8291,40 +8341,29 @@ export interface operations { }; }; }; - sendEmailBatch: { + listSnippets: { parameters: { - query?: never; - header?: { - "Idempotency-Key"?: string; + query?: { + limit?: number; + cursor?: string; + search?: string; }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["BatchSendBody"]; - }; - }; + requestBody?: never; responses: { - /** @description All entries sent */ + /** @description Snippet list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BatchSendResponse"]; - }; - }; - /** @description Partial success — at least one entry failed */ - 207: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BatchSendResponse"]; + "application/json": components["schemas"]["SnippetListResponse"]; }; }; - /** @description Validation error, or `TOO_MANY_UNIQUE_TEMPLATES` — a new account submitted more distinct message bodies in one request than content review allows. */ + /** @description Validation error */ 400: { headers: { [name: string]: unknown; @@ -8342,7 +8381,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Forbidden — insufficient permissions, project disabled, or `CONTENT_REJECTED`: the message was flagged by automated content review and was not sent. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; @@ -8351,16 +8390,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description `CONFLICT` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; @@ -8387,35 +8417,32 @@ export interface operations { "application/json": 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. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; }; }; - cancelScheduledEmail: { + createSnippet: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSnippet"]; + }; + }; responses: { - /** @description Email cancelled */ - 200: { + /** @description Snippet created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailResponse"]; + "application/json": { + data: components["schemas"]["Snippet"]; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8445,8 +8472,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { + /** @description A snippet with that name already exists in this project */ + 409: { headers: { [name: string]: unknown; }; @@ -8454,8 +8481,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already past PENDING */ - 409: { + /** @description Validation failed — request body or query parameters did not match the schema */ + 422: { headers: { [name: string]: unknown; }; @@ -8483,27 +8510,28 @@ export interface operations { }; }; }; - listContacts: { + getSnippet: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - subscribed?: "true" | "false"; - }; + query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact list */ + /** @description Snippet */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactListResponse"]; + "application/json": { + data: components["schemas"]["Snippet"]; + /** @enum {boolean} */ + success: true; + }; }; }; /** @description Validation error */ @@ -8533,8 +8561,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8562,30 +8590,24 @@ export interface operations { }; }; }; - createContact: { + deleteSnippet: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateContact"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Contact created */ - 201: { + /** @description Snippet deleted */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["IdResponse"]; }; }; /** @description Validation error */ @@ -8615,17 +8637,8 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Email already exists for this project */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8653,29 +8666,31 @@ export interface operations { }; }; }; - upsertContact: { + updateSnippet: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateContact"]; + "application/json": components["schemas"]["UpdateSnippet"]; }; }; responses: { - /** @description Contact created or updated */ + /** @description Updated snippet */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { + data: components["schemas"]["Snippet"]; /** @enum {boolean} */ success: true; - data: components["schemas"]["Contact"]; }; }; }; @@ -8706,6 +8721,24 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A snippet with that name already exists in this project */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -8735,37 +8768,26 @@ export interface operations { }; }; }; - bulkCreateContacts: { + listSuppressions: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactBulkCreateBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Bulk-create result */ + /** @description Suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - created: number; - skipped: number; - errors: { - index: number; - message: string; - }[]; - }; - }; + "application/json": components["schemas"]["SuppressionListResponse"]; }; }; /** @description Validation error */ @@ -8795,15 +8817,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8824,7 +8837,7 @@ export interface operations { }; }; }; - bulkDeleteContacts: { + addSuppression: { parameters: { query?: never; header?: never; @@ -8833,23 +8846,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContactBulkDeleteBody"]; + "application/json": components["schemas"]["AddSuppression"]; }; }; responses: { - /** @description Bulk-delete result */ - 200: { + /** @description Suppression added */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - deleted: number; - }; - }; + "application/json": components["schemas"]["Suppression"]; }; }; /** @description Validation error */ @@ -8879,15 +8886,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8908,28 +8906,25 @@ export interface operations { }; }; }; - getContact: { + checkSuppression: { parameters: { query?: never; header?: never; path: { - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact */ + /** @description Suppression check result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["SuppressionCheckResponse"]; }; }; /** @description Validation error */ @@ -8959,15 +8954,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -8988,25 +8974,24 @@ export interface operations { }; }; }; - deleteContact: { + removeSuppression: { parameters: { query?: never; header?: never; path: { - id: string; + /** @description URL-encoded email address */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Contact deleted */ - 200: { + /** @description Suppression removed */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["IdResponse"]; - }; + content?: never; }; /** @description Validation error */ 400: { @@ -9035,15 +9020,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Rate limit or billing limit exceeded */ 429: { headers: { @@ -9064,32 +9040,27 @@ export interface operations { }; }; }; - updateContact: { + listTemplates: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + cursor?: string; + search?: string; + emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateContactBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated contact */ + /** @description Template list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Contact"]; - }; + "application/json": components["schemas"]["TemplateListResponse"]; }; }; /** @description Validation error */ @@ -9119,15 +9090,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -9157,415 +9119,442 @@ export interface operations { }; }; }; - v1ListContacts: { + createTemplate: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Case-insensitive substring match on the email address. */ - search?: string; - /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ - subscribed?: "true" | "false"; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTemplate"]; + }; + }; responses: { - /** @description Contact list */ - 200: { + /** @description Template created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1List"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1CreateContact: { + getTemplate: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ContactV1Create"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The created contact */ - 201: { + /** @description Template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `conflict` — a contact with this email already exists in this project. */ - 409: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetContact: { + deleteTemplate: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The contact */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; + "application/json": components["schemas"]["IdResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Template still in use */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1DeleteContact: { + updateTemplate: { parameters: { query?: never; header?: never; path: { - /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTemplate"]; + }; + }; responses: { - /** @description Contact deleted */ + /** @description Updated template */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1Deleted"]; + "application/json": { + data: components["schemas"]["Template"]; + /** @enum {boolean} */ + success: true; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + /** @description Resource not found */ 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1UpdateContact: { + trackEvent: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ContactV1Update"]; + "application/json": components["schemas"]["TrackEvent"]; }; }; responses: { - /** @description The updated contact */ + /** @description Event tracked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["TrackEventResponse"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ - 404: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - subscribeToList: { + createProject: { parameters: { query?: never; header?: never; - path: { - /** @description List id. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ListSubscribe"]; + "application/json": { + name: string; + /** + * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. + * @enum {string} + */ + sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; + }; }; }; responses: { - /** @description Contact subscribed, or an existing membership returned unchanged */ - 200: { + /** @description Project created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListSubscribeResponse"]; + "application/json": components["schemas"]["ProjectRecord"]; }; }; /** @description Validation error */ @@ -9595,24 +9584,6 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": 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. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; /** @description Validation failed — request body or query parameters did not match the schema */ 422: { headers: { @@ -9642,102 +9613,83 @@ export interface operations { }; }; }; - unsubscribeFromList: { + v1GetCampaignAnalytics: { parameters: { - query?: never; - header?: never; - path: { - /** @description List id. */ - id: string; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListUnsubscribe"]; - }; - }; + requestBody?: never; responses: { - /** @description Contact unsubscribed */ + /** @description Campaign statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUnsubscribeResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListLists: { + v1GetAnalyticsTimeseries: { parameters: { query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; }; header?: never; path?: never; @@ -9745,13 +9697,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Subscriber lists */ + /** @description Daily time series */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1List"]; + "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9801,26 +9753,28 @@ export interface operations { }; }; }; - v1CreateList: { + v1ListTopCampaigns: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + limit?: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The created list */ - 201: { + /** @description Ranked campaigns */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9870,25 +9824,26 @@ export interface operations { }; }; }; - v1GetList: { + v1ListCampaigns: { parameters: { - query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The list */ + /** @description Campaign list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["CampaignV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9909,15 +9864,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -9947,25 +9893,29 @@ export interface operations { }; }; }; - v1DeleteList: { + v1CreateCampaign: { parameters: { query?: never; - header?: never; - path: { - /** @description Resource id. */ - id: string; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CampaignV1Create"]; + }; + }; responses: { - /** @description List deleted */ - 200: { + /** @description Campaign created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1Deleted"]; + "application/json": components["schemas"]["CampaignV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -9986,7 +9936,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — `segment_id` names a segment that does not belong to this project. */ 404: { headers: { [name: string]: unknown; @@ -9995,7 +9945,16 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; @@ -10024,7 +9983,7 @@ export interface operations { }; }; }; - v1UpdateList: { + v1GetCampaign: { parameters: { query?: never; header?: never; @@ -10034,19 +9993,15 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ListV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated list */ + /** @description The campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListV1"]; + "application/json": components["schemas"]["CampaignV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10067,7 +10022,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -10105,662 +10060,737 @@ export interface operations { }; }; }; - listDomains: { + v1DeleteCampaign: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain list */ + /** @description Campaign deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainListResponse"]; + "application/json": components["schemas"]["CampaignV1Deleted"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only `DRAFT` campaigns can be deleted. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - addDomain: { + v1UpdateCampaign: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AddDomainBody"]; + "application/json": components["schemas"]["CampaignV1Update"]; }; }; responses: { - /** @description Domain added */ - 201: { + /** @description The updated campaign */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — the campaign is not in an editable status, or the segment change is not allowed. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ - 502: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomain: { + v1CancelCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain */ + /** @description The cancelled campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only `SCHEDULED`, `SENDING`, or `PAUSED` campaigns can be cancelled. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteDomain: { + v1ListCampaignFailures: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Domain removed */ + /** @description Failed sends */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["CampaignV1FailureList"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - assignDomainStream: { + v1PauseCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AssignDomainStream"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated sending identity */ + /** @description The paused campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Domain"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `SENDING` campaign can be paused. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getDomainVerification: { + v1ResumeCampaign: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The resumed campaign */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["DomainVerificationStatus"]; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `PAUSED` campaign can be resumed. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - verifyDomain: { + v1RetryCampaignFailures: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Verification status */ + /** @description The retry was queued */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["DomainVerificationStatus"]; - }; + "application/json": components["schemas"]["CampaignV1RetryFailed"]; }; }; - /** @description Validation error */ + /** @description `validation_error` — only a `SENT` campaign can have its failed sends retried. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — a retry is already running for this campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - startDomainSetup: { + v1SendCampaign: { parameters: { query?: never; - header?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CampaignV1Send"]; + }; + }; responses: { - /** @description Guided setup session */ + /** @description The campaign, now `SENDING` or `SCHEDULED` */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - token: string; - /** - * Format: uri - * @description Open this in a browser to publish the records. Short-lived and domain-specific. - */ - connectUrl: string; - /** @description When `connectUrl` stops working. */ - expiresAt: string; - }; - }; + "application/json": components["schemas"]["CampaignV1"]; }; }; - /** @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. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": 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. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListDomains: { + v1GetCampaignStats: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Sending domain list */ + /** @description Campaign statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1List"]; + "application/json": components["schemas"]["CampaignV1Stats"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10781,6 +10811,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no campaign with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -10810,26 +10849,30 @@ export interface operations { }; }; }; - v1CreateDomain: { + v1ListContacts: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the email address. */ + search?: string; + /** @description Filter to subscribed (`true`) or unsubscribed (`false`) contacts. Omit for both. */ + subscribed?: "true" | "false"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["DomainV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The registered sending domain, awaiting DNS */ - 201: { + /** @description Contact list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10840,18 +10883,9 @@ export interface operations { content: { "application/problem+json": components["schemas"]["Problem"]; }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description `conflict` — this domain is already registered to a project you can send from. */ - 409: { + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -10886,36 +10920,28 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; }; }; - v1GetDomain: { + v1CreateContact: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Create"]; + }; + }; responses: { - /** @description The sending domain */ - 200: { + /** @description The created contact */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -10936,8 +10962,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ - 404: { + /** @description `conflict` — a contact with this email already exists in this project. */ + 409: { headers: { [name: string]: unknown; }; @@ -10974,7 +11000,7 @@ export interface operations { }; }; }; - v1DeleteDomain: { + v1GetContact: { parameters: { query?: never; header?: never; @@ -10986,13 +11012,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Sending domain removed */ + /** @description The contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1Deleted"]; + "application/json": components["schemas"]["ContactV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11013,7 +11039,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11022,15 +11048,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -11060,7 +11077,7 @@ export interface operations { }; }; }; - v1VerifyDomain: { + v1DeleteContact: { parameters: { query?: never; header?: never; @@ -11072,13 +11089,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The sending domain, as SES and DNS now report it */ + /** @description Contact deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DomainV1"]; + "application/json": components["schemas"]["ContactV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11099,7 +11116,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11137,434 +11154,379 @@ export interface operations { }; }; }; - listTemplates: { + v1UpdateContact: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - emailCategory?: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ContactV1Update"]; + }; + }; responses: { - /** @description Template list */ + /** @description The updated contact */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateListResponse"]; + "application/json": components["schemas"]["ContactV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createTemplate: { + v1GetContactTopicPreferences: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateTemplate"]; + path: { + /** @description The contact. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Template created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description The contact's preferences */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ContactTopicPreferencesV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getTemplate: { + v1DiagnoseDeliverability: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query: { + /** @description A sending domain in this project, e.g. `example.com`. */ + domain: 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. */ + address?: string; + /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ + window_days?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Template */ + /** @description The diagnosis, with findings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteTemplate: { + v1ListDmarcReports: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ + days?: number; + /** @description Restrict to reports about one of your domains. */ + domain?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Template deleted */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["IdResponse"]; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description Cursor-paginated DMARC aggregate reports */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["DmarcReportV1List"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Template still in use */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateTemplate: { + v1ListRecipientDomainStats: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ + days?: number; + /** @description Restrict to one recipient domain. */ + domain?: string; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateTemplate"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated template */ + /** @description Cursor-paginated recipient-domain rollup */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Template"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["RecipientDomainStatsV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListTemplates: { + v1ListDomains: { parameters: { query?: { limit?: number; /** @description Opaque cursor from a previous response's `next_cursor`. */ after?: string; - /** @description Case-insensitive substring match on the name. */ - search?: string; - email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; }; header?: never; path?: never; @@ -11572,13 +11534,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Template list */ + /** @description Sending domain list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1List"]; + "application/json": components["schemas"]["DomainV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11628,7 +11590,7 @@ export interface operations { }; }; }; - v1CreateTemplate: { + v1CreateDomain: { parameters: { query?: never; header?: never; @@ -11637,17 +11599,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TemplateV1Create"]; + "application/json": components["schemas"]["DomainV1Create"]; }; }; responses: { - /** @description The created template */ + /** @description The registered sending domain, awaiting DNS */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11668,6 +11630,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `conflict` — this domain is already registered to a project you can send from. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -11695,9 +11666,18 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @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. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; }; }; - v1GetTemplate: { + v1GetDomain: { parameters: { query?: never; header?: never; @@ -11709,13 +11689,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The template */ + /** @description The sending domain */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11736,7 +11716,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11774,7 +11754,7 @@ export interface operations { }; }; }; - v1DeleteTemplate: { + v1DeleteDomain: { parameters: { query?: never; header?: never; @@ -11786,13 +11766,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Template deleted */ + /** @description Sending domain removed */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1Deleted"]; + "application/json": components["schemas"]["DomainV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11813,7 +11793,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11822,7 +11802,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + /** @description `conflict` — the domain is still in use by a template, workflow step or active campaign. */ 409: { headers: { [name: string]: unknown; @@ -11860,7 +11840,7 @@ export interface operations { }; }; }; - v1UpdateTemplate: { + v1VerifyDomain: { parameters: { query?: never; header?: never; @@ -11870,19 +11850,15 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TemplateV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated template */ + /** @description The sending domain, as SES and DNS now report it */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TemplateV1"]; + "application/json": components["schemas"]["DomainV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -11903,7 +11879,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no sending domain with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -11941,85 +11917,175 @@ export interface operations { }; }; }; - listSnippets: { + v1ValidateEmails: { parameters: { - query?: { - limit?: number; - cursor?: string; - search?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + }; + }; responses: { - /** @description Snippet list */ + /** @description One verdict per address, in the order they were given */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SnippetListResponse"]; + "application/json": components["schemas"]["EmailValidationBatchV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `internal_error`. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + v1SendEmail: { + parameters: { + query?: never; + header?: { + /** @description Replay-safety key (24h TTL). Reuse it only to retry the identical request. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmailV1"]; + }; + }; + responses: { + /** @description Email queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailV1"]; + }; + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation error */ - 400: { + /** @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. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `resource_not_found` — `template` names a template that does not belong to this project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `conflict` — a request with this `Idempotency-Key` is still in flight. Retry shortly. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ + /** @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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createSnippet: { + v1SendTestEmail: { parameters: { query?: never; header?: never; @@ -12028,887 +12094,825 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateSnippet"]; + "application/json": components["schemas"]["SendTestEmailV1"]; }; }; responses: { - /** @description Snippet created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description Test email queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailTestV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @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: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description A snippet with that name already exists in this project */ + /** @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. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation failed — request body or query parameters did not match the schema */ + /** @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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `content_review_unavailable` — content review could not run for this new account. Safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getSnippet: { + v1ListEvents: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only events with this exact name. */ + event_name?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Snippet */ + /** @description Event list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EventV1List"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteSnippet: { + v1TrackEvent: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EventTrackV1"]; + }; + }; responses: { - /** @description Snippet deleted */ - 200: { + /** @description Event recorded */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IdResponse"]; + "application/json": components["schemas"]["EventV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no contact with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateSnippet: { + v1ListEventNames: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateSnippet"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated snippet */ + /** @description Event names */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Snippet"]; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Unauthorized — missing or invalid auth */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EventNamesV1"]; }; - }; - /** @description Resource not found */ - 404: { + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description A snippet with that name already exists in this project */ - 409: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": 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. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhooks: { + v1GetEventStats: { parameters: { - query?: never; + query?: { + /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ + from?: string | null; + /** @description End of the window (ISO 8601). Defaults to now. */ + to?: string | null; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook list */ + /** @description Event counts */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookListResponse"]; + "application/json": components["schemas"]["EventStatsV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createWebhook: { + v1ListLists: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook created */ - 201: { + /** @description Subscriber lists */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookCreateResponse"]; + "application/json": components["schemas"]["ListV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getWebhook: { + v1CreateList: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Webhook */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookGetResponse"]; - }; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Create"]; }; - /** @description Validation error */ - 400: { + }; + responses: { + /** @description The created list */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteWebhook: { + v1GetList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook deleted */ + /** @description The list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - updateWebhook: { + v1DeleteList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateWebhook"]; - }; - }; + requestBody?: never; responses: { - /** @description Webhook updated */ + /** @description List deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookGetResponse"]; + "application/json": components["schemas"]["ListV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateWebhookSecret: { + v1UpdateList: { parameters: { query?: never; header?: never; path: { + /** @description Resource id. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ListV1Update"]; + }; + }; responses: { - /** @description Secret rotated */ + /** @description The updated list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookRotateSecretResponse"]; + "application/json": components["schemas"]["ListV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listWebhookCalls: { + v1StartListValidationRun: { parameters: { - query?: { - limit?: number; - cursor?: string; - }; + query?: never; header?: never; path: { + /** @description The list to validate. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook call history */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookCallsListResponse"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The run, accepted and queued */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListWebhooks: { + v1GetProject: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook list */ + /** @description The authenticated project */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1List"]; + "application/json": components["schemas"]["ProjectV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -12929,6 +12933,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — the project was deleted between authentication and this read. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -12958,26 +12971,26 @@ export interface operations { }; }; }; - v1CreateWebhook: { + v1ListSegments: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookV1Create"]; - }; - }; + requestBody?: never; responses: { - /** @description The created webhook and its one-time signing secret */ - 201: { + /** @description Segment list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1Created"]; + "application/json": components["schemas"]["SegmentV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13027,29 +13040,30 @@ export interface operations { }; }; }; - v1GetWebhook: { + v1CreateSegment: { parameters: { query?: never; header?: never; - path: { - /** @description Resource id. */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Create"]; + }; + }; responses: { - /** @description The webhook */ - 200: { + /** @description Segment created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description `validation_error` — a `DYNAMIC` segment was submitted without a `condition`. */ + 400: { headers: { [name: string]: unknown; }; @@ -13057,8 +13071,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; @@ -13066,8 +13080,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -13104,7 +13118,7 @@ export interface operations { }; }; }; - v1DeleteWebhook: { + v1GetSegment: { parameters: { query?: never; header?: never; @@ -13116,13 +13130,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Webhook deleted */ + /** @description The segment */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1Deleted"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13143,7 +13157,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13181,7 +13195,7 @@ export interface operations { }; }; }; - v1UpdateWebhook: { + v1DeleteSegment: { parameters: { query?: never; header?: never; @@ -13191,19 +13205,15 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookV1Update"]; - }; - }; + requestBody?: never; responses: { - /** @description The updated webhook */ + /** @description Segment deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1"]; + "application/json": components["schemas"]["SegmentV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13224,7 +13234,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13233,6 +13243,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `conflict` — the segment is still used by one or more active campaigns. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -13262,7 +13281,7 @@ export interface operations { }; }; }; - v1RotateWebhookSecret: { + v1UpdateSegment: { parameters: { query?: never; header?: never; @@ -13272,15 +13291,19 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SegmentV1Update"]; + }; + }; responses: { - /** @description The new signing secret and the moment the previous one stops verifying */ + /** @description The updated segment */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebhookV1SecretRotated"]; + "application/json": components["schemas"]["SegmentV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13301,7 +13324,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ 404: { headers: { [name: string]: unknown; @@ -13339,639 +13362,609 @@ export interface operations { }; }; }; - listApiKeys: { + v1ListSegmentContacts: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path: { - /** @description Project id. */ + /** @description Resource id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key list */ + /** @description Segment member list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiKeyListResponse"]; + "application/json": components["schemas"]["SegmentContactV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no segment with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createApiKey: { + v1ListSuppressions: { parameters: { - query?: never; - header?: never; - path: { - /** @description Project id. */ - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateApiKeyBody"]; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Filter to one reason. Omit for every suppressed address. */ + reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { - /** @description API key created; the secret is behind the reveal link. */ - 201: { + /** @description Suppression list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - /** @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. */ - data: components["schemas"]["ApiKey"] & { - /** - * Format: uri - * @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. - */ - revealUrl: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - }; - }; + "application/json": components["schemas"]["SuppressionV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - rotateApiKey: { + v1CreateSuppression: { parameters: { query?: never; header?: never; - path: { - /** @description Project id. */ - id: string; - /** @description API key id. */ - keyId: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description API key rotated; the new secret is behind the reveal link. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - lastFour: string; - /** - * Format: uri - * @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. - */ - revealUrl: string; - /** - * Format: date-time - * @description When the reveal link stops working. Create or rotate again to get a new one. - */ - revealExpiresAt: string; - }; - }; - }; + requestBody: { + content: { + "application/json": components["schemas"]["SuppressionV1Create"]; }; - /** @description Validation error */ - 400: { + }; + responses: { + /** @description The suppressed address */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - revokeApiKey: { + v1GetSuppression: { parameters: { query?: never; header?: never; path: { - /** @description Project id. */ - id: string; - /** @description API key id. */ - keyId: string; + /** @description The suppressed address, URL-encoded. */ + email: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description API key revoked */ + /** @description The suppression record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessEmpty"]; + "application/json": components["schemas"]["SuppressionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listSuppressions: { + v1DeleteSuppression: { parameters: { - query?: { - limit?: number; - cursor?: string; - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description The suppressed address, URL-encoded. */ + email: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression list */ + /** @description Address removed from the suppression list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionListResponse"]; + "application/json": components["schemas"]["SuppressionV1Deleted"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - addSuppression: { + v1ListTemplates: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Case-insensitive substring match on the name. */ + search?: string; + email_category?: "TRANSACTIONAL" | "MARKETING" | "SELF_MANAGED_UNSUBSCRIBE"; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AddSuppression"]; - }; - }; + requestBody?: never; responses: { - /** @description Suppression added */ - 201: { + /** @description Template list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Suppression"]; + "application/json": components["schemas"]["TemplateV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - checkSuppression: { + v1CreateTemplate: { parameters: { query?: never; header?: never; - path: { - /** @description URL-encoded email address */ - email: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateV1Create"]; + }; + }; responses: { - /** @description Suppression check result */ - 200: { + /** @description The created template */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionCheckResponse"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - removeSuppression: { + v1GetTemplate: { parameters: { query?: never; header?: never; path: { - /** @description URL-encoded email address */ - email: string; + /** @description Resource id. */ + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression removed */ - 204: { + /** @description The template */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["TemplateV1"]; + }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListSuppressions: { + v1DeleteTemplate: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Filter to one reason. Omit for every suppressed address. */ - reason?: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suppression list */ + /** @description Template deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1List"]; + "application/json": components["schemas"]["TemplateV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -13992,6 +13985,24 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `conflict` — the template is still referenced by a workflow step or an active campaign. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14021,26 +14032,29 @@ export interface operations { }; }; }; - v1CreateSuppression: { + v1UpdateTemplate: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SuppressionV1Create"]; + "application/json": components["schemas"]["TemplateV1Update"]; }; }; responses: { - /** @description The suppressed address */ - 201: { + /** @description The updated template */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1"]; + "application/json": components["schemas"]["TemplateV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14061,6 +14075,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no template with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14090,25 +14113,27 @@ export interface operations { }; }; }; - v1GetSuppression: { + v1ListTopics: { parameters: { - query?: never; - header?: never; - path: { - /** @description The suppressed address, URL-encoded. */ - email: string; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + include_archived?: boolean | null; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The suppression record */ + /** @description One page of topics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1"]; + "application/json": components["schemas"]["TopicListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14129,15 +14154,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — this address is not suppressed for the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14167,25 +14183,26 @@ export interface operations { }; }; }; - v1DeleteSuppression: { + v1CreateTopic: { parameters: { query?: never; header?: never; - path: { - /** @description The suppressed address, URL-encoded. */ - email: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicCreateV1"]; + }; + }; responses: { - /** @description Address removed from the suppression list */ - 200: { + /** @description The created topic */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuppressionV1Deleted"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14235,97 +14252,97 @@ export interface operations { }; }; }; - trackEvent: { + v1GetTopic: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TrackEvent"]; + path: { + /** @description The topic. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Event tracked */ + /** @description The topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TrackEventResponse"]; + "application/json": components["schemas"]["TopicV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListEvents: { + v1UpdateTopic: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description Return only events with this exact name. */ - event_name?: string; + query?: never; + header?: never; + path: { + /** @description The topic. */ + id: string; }; - header?: never; - path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TopicUpdateV1"]; + }; + }; responses: { - /** @description Event list */ + /** @description The updated topic */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1List"]; + "application/json": components["schemas"]["TopicV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14375,26 +14392,29 @@ export interface operations { }; }; }; - v1TrackEvent: { + v1SetTopicSubscription: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The topic. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["EventTrackV1"]; + "application/json": components["schemas"]["TopicSubscribeV1"]; }; }; responses: { - /** @description Event recorded */ - 201: { + /** @description The resulting subscription */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventV1"]; + "application/json": components["schemas"]["TopicSubscriptionV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14415,15 +14435,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — no contact with this id in the authenticated project. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14453,7 +14464,7 @@ export interface operations { }; }; }; - v1ListEventNames: { + v1GetUsage: { parameters: { query?: never; header?: never; @@ -14462,13 +14473,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Event names */ + /** @description Current usage */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventNamesV1"]; + "application/json": components["schemas"]["UsageV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14518,27 +14529,25 @@ export interface operations { }; }; }; - v1GetEventStats: { + v1GetValidationRun: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description The validation run. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Event counts */ + /** @description The run */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStatsV1"]; + "application/json": components["schemas"]["EmailValidationRunV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14588,27 +14597,31 @@ export interface operations { }; }; }; - v1GetAnalyticsTimeseries: { + v1ListValidationRunResults: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ + verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; }; header?: never; - path?: never; + path: { + /** @description The validation run. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Daily time series */ + /** @description One page of results */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTimeseriesV1"]; + "application/json": components["schemas"]["EmailValidationResultListV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14658,13 +14671,12 @@ export interface operations { }; }; }; - v1GetCampaignAnalytics: { + v1ListWebhooks: { parameters: { query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; }; header?: never; path?: never; @@ -14672,13 +14684,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Campaign statistics */ + /** @description Webhook list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsCampaignStatsV1"]; + "application/json": components["schemas"]["WebhookV1List"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14728,28 +14740,26 @@ export interface operations { }; }; }; - v1ListTopCampaigns: { + v1CreateWebhook: { parameters: { - query?: { - /** @description Start of the window (ISO 8601). Defaults to 30 days ago; clamped to at most 90 days back. */ - from?: string | null; - /** @description End of the window (ISO 8601). Defaults to now. */ - to?: string | null; - limit?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Create"]; + }; + }; responses: { - /** @description Ranked campaigns */ - 200: { + /** @description The created webhook and its one-time signing secret */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AnalyticsTopCampaignsV1"]; + "application/json": components["schemas"]["WebhookV1Created"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14799,29 +14809,25 @@ export interface operations { }; }; }; - v1DiagnoseDeliverability: { + v1GetWebhook: { parameters: { - query: { - /** @description A sending domain in this project, e.g. `example.com`. */ - domain: 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. */ - address?: string; - /** @description How far back the delivery counters look. 1–30 days; defaults to 7. */ - window_days?: number; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description The diagnosis, with findings */ + /** @description The webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DeliverabilityDiagnosisV1"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14842,6 +14848,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14871,30 +14886,25 @@ export interface operations { }; }; }; - v1ListRecipientDomainStats: { + v1DeleteWebhook: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description How far back to read. 1-30 days; defaults to 30, which is the window the job maintains. */ - days?: number; - /** @description Restrict to one recipient domain. */ - domain?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Cursor-paginated recipient-domain rollup */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecipientDomainStatsV1List"]; + "application/json": components["schemas"]["WebhookV1Deleted"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14915,6 +14925,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14944,30 +14963,29 @@ export interface operations { }; }; }; - v1ListDmarcReports: { + v1UpdateWebhook: { parameters: { - query?: { - limit?: number; - /** @description Opaque cursor from a previous response's `next_cursor`. */ - after?: string; - /** @description How far back to read, by the report's window start. 1-180 days; defaults to 30. */ - days?: number; - /** @description Restrict to reports about one of your domains. */ - domain?: string; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookV1Update"]; + }; + }; responses: { - /** @description Cursor-paginated DMARC aggregate reports */ + /** @description The updated webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DmarcReportV1List"]; + "application/json": components["schemas"]["WebhookV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -14988,6 +15006,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15017,22 +15044,25 @@ export interface operations { }; }; }; - v1GetUsage: { + v1RotateWebhookSecret: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Resource id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Current usage */ + /** @description The new signing secret and the moment the previous one stops verifying */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UsageV1"]; + "application/json": components["schemas"]["WebhookV1SecretRotated"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -15053,6 +15083,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no webhook with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15082,35 +15121,30 @@ export interface operations { }; }; }; - v1GetProject: { + v1ListWorkflows: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The authenticated project */ + /** @description Workflow list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectV1"]; - }; - }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["WorkflowV1List"]; }; - }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + }; + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; @@ -15118,8 +15152,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description `resource_not_found` — the project was deleted between authentication and this read. */ - 404: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; @@ -15156,962 +15190,845 @@ export interface operations { }; }; }; - listMailboxes: { + v1CreateWorkflow: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowCreateV1"]; + }; + }; responses: { - /** @description Mailbox list */ - 200: { + /** @description Workflow created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Mailbox"][]; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createMailbox: { + v1CancelWorkflowExecution: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateMailboxBody"]; + path: { + /** @description Workflow execution id. */ + execution_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Mailbox provisioned */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["Mailbox"]; - }; - }; - }; - /** @description Validation error */ - 400: { + /** @description Cancelled execution */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowExecutionV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no execution with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The address already exists, the domain is not verified, or the project is at its 10-mailbox limit. */ - 409: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Provisioning failed in the mail server. The mailbox row is left `FAILED` and can be retried. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - getMailbox: { + v1GetWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox with connection settings */ + /** @description Workflow */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["MailboxDetail"]; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - deleteMailbox: { + v1DeleteWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Mailbox deleted */ + /** @description Workflow deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - deleted: true; - }; - }; + "application/json": components["schemas"]["WorkflowDeletedV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the workflow still has running executions. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - draftMailboxMessage: { + v1UpdateWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["DraftMailboxMessage"]; + "application/json": components["schemas"]["WorkflowUpdateV1"]; }; }; responses: { - /** @description A draft. Nothing was sent. */ + /** @description Updated workflow */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @description Suggested subject, or null. */ - subject: string | null; - /** @description Suggested plain-text body, or null. */ - body: string | null; - /** @description Alternative subject lines (`subject` mode); empty otherwise. */ - subjects: string[]; - /** - * @description Always false. Reported rather than assumed, so a draft cannot be mistaken for a send. - * @enum {boolean} - */ - sent: false; - }; - }; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; - }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the trigger cannot be changed while executions are running. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The drafting model was unreachable or returned nothing usable. */ - 502: { + /** @description `internal_error`. */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - sendMailboxMessage: { + v1CloneWorkflow: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["ComposeMailboxMessage"]; + "application/json": components["schemas"]["WorkflowCloneV1"]; }; }; responses: { - /** @description Message submitted */ + /** @description The cloned workflow */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - submitted: true; - /** - * Format: uuid - * @description The conversation this send started. Replies thread onto it. - */ - conversationId: string; - /** - * Format: uuid - * @description The stored outbound message. - */ - messageId: string; - }; - }; - }; - }; - /** @description Validation error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The mailbox is not active, a recipient is suppressed (`RECIPIENT_SUPPRESSED`), or the content scanner refused the message (`CONTENT_REFUSED`). */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description The mail server refused the submission. Nothing was sent. */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Message screening could not reach a verdict. Nothing was sent; retry shortly. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - listAppPasswords: { + v1ListWorkflowExecutions: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's `next_cursor`. */ + after?: string; + /** @description Return only executions in this state. */ + status?: "RUNNING" | "WAITING" | "COMPLETED" | "EXITED" | "FAILED" | "CANCELLED"; + }; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description App password list */ + /** @description Execution list */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["AppPassword"][]; - }; + "application/json": components["schemas"]["WorkflowExecutionV1List"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createAppPassword: { + v1StartWorkflowExecution: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateAppPassword"]; + "application/json": components["schemas"]["WorkflowExecutionStartV1"]; }; }; responses: { - /** @description App password created; the secret is behind the one-time link */ + /** @description Execution started */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: components["schemas"]["AppPasswordReveal"]; - }; + "application/json": components["schemas"]["WorkflowExecutionV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no such workflow, or no such contact in this project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `conflict` — the contact already has an execution and re-entry is not allowed. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - revokeAppPassword: { + v1GetWorkflowGraph: { parameters: { query?: never; header?: never; path: { + /** @description Workflow id. */ id: string; - passwordId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description App password revoked */ + /** @description The workflow's graph */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - /** @enum {boolean} */ - success: true; - data: { - /** @enum {boolean} */ - revoked: true; - }; - }; + "application/json": components["schemas"]["WorkflowGraphV1"]; }; }; - /** @description Validation error */ - 400: { + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized — missing or invalid auth */ - 401: { + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ - 403: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Resource not found */ - 404: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createProject: { + v1ReplaceWorkflowGraph: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Workflow id. */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": { - name: string; - /** - * @description AWS SES region for the project. Once a domain is added the region is locked and cannot be changed. - * @enum {string} - */ - sesRegion?: "us-east-1" | "us-west-2" | "eu-west-1"; - }; + "application/json": components["schemas"]["WorkflowGraphReplaceV1"]; }; }; responses: { - /** @description Project created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ProjectRecord"]; - }; - }; - /** @description Validation error */ - 400: { + /** @description The graph as it now stands */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/json": components["schemas"]["WorkflowGraphV1"]; }; }; - /** @description Unauthorized — missing or invalid auth */ + /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden — insufficient permissions or project disabled */ + /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Validation failed — request body or query parameters did not match the schema */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Rate limit or billing limit exceeded */ - 429: { + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ - 500: { + /** @description `conflict` — the workflow has running executions, or an id in the document already belongs to a different workflow. */ + 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - }; - verifyEmailAddress: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["VerifyEmail"]; - }; - }; - responses: { - /** @description Verification result */ - 200: { + /** @description `validation_error` — query, path, or body parameters did not match the schema. */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VerifyEmailResponse"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Validation error */ - 400: { + /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + 429: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal server error */ + /** @description `internal_error`. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - v1ListTopics: { + v1PauseWorkflow: { parameters: { - query?: { - cursor?: string; - limit?: number; - include_archived?: boolean | null; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description Workflow id. */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description One page of topics */ + /** @description The workflow, and the number of runs this call cancelled */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicListV1"]; + "application/json": components["schemas"]["WorkflowStateChangeV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16132,6 +16049,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16161,26 +16087,25 @@ export interface operations { }; }; }; - v1CreateTopic: { + v1ResumeWorkflow: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TopicCreateV1"]; + path: { + /** @description Workflow id. */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The created topic */ - 201: { + /** @description The workflow, with `cancelled_executions` always 0 */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["WorkflowStateChangeV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16201,6 +16126,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16230,25 +16164,28 @@ export interface operations { }; }; }; - v1GetTopic: { + v1GetWorkflowStats: { parameters: { - query?: never; + query?: { + /** @description Only count executions started at or after this instant (ISO 8601). Defaults to all time. */ + from?: string | null; + }; header?: never; path: { - /** @description The topic. */ + /** @description Workflow id. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The topic */ + /** @description Workflow statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["WorkflowStatsV1"]; }; }; /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ @@ -16269,6 +16206,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no workflow with this id in the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -16298,492 +16244,565 @@ export interface operations { }; }; }; - v1UpdateTopic: { + verifyEmailAddress: { parameters: { query?: never; header?: never; - path: { - /** @description The topic. */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["TopicUpdateV1"]; + "application/json": components["schemas"]["VerifyEmail"]; }; }; responses: { - /** @description The updated topic */ + /** @description Verification result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicV1"]; + "application/json": components["schemas"]["VerifyEmailResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + }; + }; + listWebhooks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook list */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["WebhookListResponse"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1SetTopicSubscription: { + createWebhook: { parameters: { - query?: never; - header?: never; - path: { - /** @description The topic. */ - id: string; - }; + query?: never; + header?: never; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["TopicSubscribeV1"]; + "application/json": components["schemas"]["CreateWebhook"]; }; }; responses: { - /** @description The resulting subscription */ - 200: { + /** @description Webhook created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicSubscriptionV1"]; + "application/json": components["schemas"]["WebhookCreateResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ - 401: { + /** @description Validation error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ - 403: { + /** @description Unauthorized — missing or invalid auth */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Forbidden — insufficient permissions or project disabled */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetContactTopicPreferences: { + getWebhook: { parameters: { query?: never; header?: never; path: { - /** @description The contact. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The contact's preferences */ + /** @description Webhook */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ContactTopicPreferencesV1"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ValidateEmails: { + deleteWebhook: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["EmailValidationBatchRequestV1"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description One verdict per address, in the order they were given */ + /** @description Webhook deleted */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationBatchV1"]; + "application/json": components["schemas"]["SuccessEmpty"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1StartListValidationRun: { + updateWebhook: { parameters: { query?: never; header?: never; path: { - /** @description The list to validate. */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebhook"]; + }; + }; responses: { - /** @description The run, accepted and queued */ - 202: { + /** @description Webhook updated */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationRunV1"]; + "application/json": components["schemas"]["WebhookGetResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1GetValidationRun: { + listWebhookCalls: { parameters: { - query?: never; + query?: { + limit?: number; + cursor?: string; + }; header?: never; path: { - /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The run */ + /** @description Webhook call history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationRunV1"]; + "application/json": components["schemas"]["WebhookCallsListResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - v1ListValidationRunResults: { + rotateWebhookSecret: { parameters: { - query?: { - cursor?: string; - limit?: number; - /** @description Return only results with this verdict — `undeliverable` is the usual filter. */ - verdict?: components["schemas"]["EmailValidationVerdictV1"] & unknown; - }; + query?: never; header?: never; path: { - /** @description The validation run. */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description One page of results */ + /** @description Secret rotated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EmailValidationResultListV1"]; + "application/json": components["schemas"]["WebhookRotateSecretResponse"]; }; }; - /** @description `invalid_api_key` or `invalid_session` — missing or invalid credentials. */ + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized — missing or invalid auth */ 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `scope_missing`, `project_access_denied`, or `project_disabled`. */ + /** @description Forbidden — insufficient permissions or project disabled */ 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `validation_error` — query, path, or body parameters did not match the schema. */ - 422: { + /** @description Resource not found */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `rate_limited` — see `Retry-After` and the `RateLimit` headers. */ + /** @description Rate limit or billing limit exceeded */ 429: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description `internal_error`. */ + /** @description Internal server error */ 500: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "application/json": components["schemas"]["Error"]; }; }; }; From 44af1214059ea0433779423c3363415055bc004f Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 05:39:20 -0400 Subject: [PATCH 3/7] chore(spec): re-vendor the contract released at 57826bad The 1.1 branch vendored the contract as it stood on 2026-09-05. The platform has since deployed 57826bad (2026-09-06), and that deploy changed seventeen operations and eight schemas on the published wire, so the copy the contract tests read was describing an API that is no longer the one running. Taken from apps/web/openapi/openapi.json at 57826bad, not from api.sendly.now: a spec synced from the deployed API makes the SDK mirror what is RUNNING rather than what the platform DECLARES, which launders code-vs-contract drift into "correct" and destroys the one job the vendored copy has. src/types.generated.ts is `pnpm build:types` over the new file, nothing else. --- openapi.json | 367 +++++++++++++++++++++++++++++------------ src/types.generated.ts | 182 +++++++++++++++----- 2 files changed, 400 insertions(+), 149 deletions(-) diff --git a/openapi.json b/openapi.json index 4379966..2b76977 100644 --- a/openapi.json +++ b/openapi.json @@ -2049,6 +2049,10 @@ "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": [ @@ -2060,7 +2064,8 @@ "required": [ "data", "has_more", - "next_cursor" + "next_cursor", + "intake_configured" ], "type": "object" }, @@ -2079,32 +2084,60 @@ "null" ] }, - "dkim": { + "dkimStatus": { + "description": "Result of the last DNS check for this record type.", + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "type": [ + "string", + "null" + ] + }, + "dkimTokens": { + "description": "SES DKIM tokens to publish as CNAME records before the domain can verify.", "items": { - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "value" - ], - "type": "object" + "type": "string" }, - "type": "array" + "type": [ + "array", + "null" + ] + }, + "dmarcStatus": { + "description": "Result of the last DNS check for this record type.", + "enum": [ + "NOT_CHECKED", + "PENDING", + "VERIFIED", + "FAILED", + null + ], + "type": [ + "string", + "null" + ] + }, + "domain": { + "description": "The bare domain, e.g. `mail.acme.com`.", + "type": "string" }, "id": { "format": "uuid", "type": "string" }, + "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": [ @@ -2127,19 +2160,34 @@ "null" ] }, - "name": { - "type": "string" - }, "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", + "VERIFIED", + "FAILED", + null + ], + "type": [ + "string", + "null" + ] + }, "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": [ @@ -2168,8 +2216,9 @@ "required": [ "id", "projectId", - "name", + "domain", "verified", + "receivingEnabled", "createdAt", "updatedAt" ], @@ -2367,27 +2416,24 @@ "DomainVerificationStatus": { "description": "Outcome of a verification check against SES.", "properties": { - "dkim": { - "items": { - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "value" - ], - "type": "object" - }, - "type": "array" + "dkimStatus": { + "enum": [ + "VERIFIED", + "PENDING", + "FAILED" + ], + "type": "string" + }, + "dmarcStatus": { + "enum": [ + "VERIFIED", + "FAILED", + "NOT_CHECKED" + ], + "type": "string" + }, + "domain": { + "type": "string" }, "mailFromDomain": { "type": [ @@ -2410,7 +2456,20 @@ "null" ] }, - "mxRecords": { + "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": { "type": "string" }, @@ -2421,7 +2480,13 @@ } }, "required": [ - "verified" + "domain", + "status", + "verified", + "dkimStatus", + "spfStatus", + "dmarcStatus", + "mailFromDomain" ], "type": "object" }, @@ -4898,6 +4963,14 @@ ], "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", @@ -4913,6 +4986,7 @@ "email", "reason", "source", + "scope", "createdAt" ], "type": "object" @@ -4952,39 +5026,25 @@ "type": "object" }, "SuppressionListResponse": { - "description": "Cursor-paginated list of suppressions.", + "description": "Cursor-paginated list of suppressions. NOTE: this route answers a bare body — there is no `{success, data}` envelope.", "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "data": { + "items": { "items": { "$ref": "#/components/schemas/Suppression" }, "type": "array" }, - "hasMore": { - "type": "boolean" - }, "nextCursor": { + "description": "Cursor for the next page, or `null` on the last page. Never omitted.", "type": [ "string", "null" ] - }, - "success": { - "enum": [ - true - ], - "type": "boolean" } }, "required": [ - "success", - "data" + "items", + "nextCursor" ], "type": "object" }, @@ -5099,6 +5159,10 @@ "format": "date-time", "type": "string" }, + "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", @@ -5158,6 +5222,7 @@ "body", "from", "emailCategory", + "currentVersion", "createdAt", "updatedAt" ], @@ -6022,7 +6087,7 @@ "type": "object" }, "Webhook": { - "description": "A user-managed outbound webhook.", + "description": "A user-managed outbound webhook. Never carries a secret.", "properties": { "consecutiveFailures": { "type": "integer" @@ -6040,6 +6105,13 @@ "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": [ @@ -6062,8 +6134,13 @@ "format": "uuid", "type": "string" }, - "lastFour": { - "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", @@ -6093,6 +6170,7 @@ "url", "eventTypes", "status", + "domains", "consecutiveFailures", "createdAt", "updatedAt" @@ -6198,24 +6276,20 @@ "description": "Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely.", "properties": { "data": { - "allOf": [ - { - "$ref": "#/components/schemas/Webhook" + "properties": { + "secret": { + "description": "Plaintext shared secret. Returned ONCE on create.", + "type": "string" }, - { - "properties": { - "secret": { - "description": "Plaintext shared secret. Returned ONCE on create.", - "type": "string" - } - }, - "required": [ - "secret" - ], - "type": "object" + "webhook": { + "$ref": "#/components/schemas/Webhook" } + }, + "required": [ + "webhook", + "secret" ], - "description": "A user-managed outbound webhook." + "type": "object" }, "success": { "enum": [ @@ -16975,12 +17049,13 @@ "operationId": "v1GetContactTopicPreferences", "parameters": [ { - "description": "The contact.", + "description": "Resource id.", "in": "path", "name": "id", "required": true, "schema": { - "description": "The contact.", + "description": "Resource id.", + "format": "uuid", "type": "string" } } @@ -17016,6 +17091,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 contact with this id belongs to the authenticated project." + }, "422": { "content": { "application/problem+json": { @@ -19319,12 +19404,13 @@ "operationId": "v1StartListValidationRun", "parameters": [ { - "description": "The list to validate.", + "description": "Resource id.", "in": "path", "name": "id", "required": true, "schema": { - "description": "The list to validate.", + "description": "Resource id.", + "format": "uuid", "type": "string" } } @@ -19360,6 +19446,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 list with this id belongs to the authenticated project." + }, "422": { "content": { "application/problem+json": { @@ -21394,12 +21490,13 @@ "operationId": "v1GetTopic", "parameters": [ { - "description": "The topic.", + "description": "Resource id.", "in": "path", "name": "id", "required": true, "schema": { - "description": "The topic.", + "description": "Resource id.", + "format": "uuid", "type": "string" } } @@ -21435,6 +21532,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 topic with this id belongs to the authenticated project." + }, "422": { "content": { "application/problem+json": { @@ -21489,12 +21596,13 @@ "operationId": "v1UpdateTopic", "parameters": [ { - "description": "The topic.", + "description": "Resource id.", "in": "path", "name": "id", "required": true, "schema": { - "description": "The topic.", + "description": "Resource id.", + "format": "uuid", "type": "string" } } @@ -21540,6 +21648,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 topic with this id belongs to the authenticated project." + }, "422": { "content": { "application/problem+json": { @@ -21596,12 +21714,13 @@ "operationId": "v1SetTopicSubscription", "parameters": [ { - "description": "The topic.", + "description": "Resource id.", "in": "path", "name": "id", "required": true, "schema": { - "description": "The topic.", + "description": "Resource id.", + "format": "uuid", "type": "string" } } @@ -21647,6 +21766,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 topic with this id belongs to the authenticated project." + }, "422": { "content": { "application/problem+json": { @@ -21788,12 +21917,13 @@ "operationId": "v1GetValidationRun", "parameters": [ { - "description": "The validation run.", + "description": "Resource id.", "in": "path", "name": "id", "required": true, "schema": { - "description": "The validation run.", + "description": "Resource id.", + "format": "uuid", "type": "string" } } @@ -21829,6 +21959,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": { @@ -21885,12 +22025,13 @@ "operationId": "v1ListValidationRunResults", "parameters": [ { - "description": "The validation run.", + "description": "Resource id.", "in": "path", "name": "id", "required": true, "schema": { - "description": "The validation run.", + "description": "Resource id.", + "format": "uuid", "type": "string" } }, @@ -21964,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": { @@ -23020,7 +23171,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": { @@ -23136,7 +23287,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": { @@ -23252,7 +23403,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": { @@ -23380,7 +23531,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": { @@ -23528,7 +23679,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": { @@ -23762,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": { @@ -23878,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": { @@ -23996,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": { @@ -24104,7 +24255,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": { @@ -24226,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": { diff --git a/src/types.generated.ts b/src/types.generated.ts index 2a073b4..365a129 100644 --- a/src/types.generated.ts +++ b/src/types.generated.ts @@ -3283,6 +3283,8 @@ export interface components { DmarcReportV1List: { data: components["schemas"]["DmarcReportV1"][]; has_more: boolean; + /** @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. */ + intake_configured: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; @@ -3295,13 +3297,27 @@ export interface components { createdAt: string; /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ defaultFromAddress?: string | null; - dkim?: { - name: string; - type: string; - value: string; - }[]; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + dkimStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description SES DKIM tokens to publish as CNAME records before the domain can verify. */ + dkimTokens?: string[] | null; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + dmarcStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description The bare domain, e.g. `mail.acme.com`. */ + domain: string; /** Format: uuid */ id: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + lastHealthCheckAt?: string | null; /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ mailFromDomain?: string | null; /** @@ -3309,10 +3325,16 @@ export interface components { * @enum {string|null} */ mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - name: string; /** Format: uuid */ projectId: string; + /** @description Whether inbound mail for this domain is routed to Sendly mailboxes. */ + receivingEnabled: boolean; region?: string | null; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + spfStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; /** * @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 {string|null} @@ -3380,18 +3402,23 @@ export interface components { }; /** @description Outcome of a verification check against SES. */ DomainVerificationStatus: { - dkim?: { - name: string; - type: string; - value: string; - }[]; - mailFromDomain?: string | null; + /** @enum {string} */ + dkimStatus: "VERIFIED" | "PENDING" | "FAILED"; + /** @enum {string} */ + dmarcStatus: "VERIFIED" | "FAILED" | "NOT_CHECKED"; + domain: string; + mailFromDomain: string | null; /** * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. * @enum {string|null} */ mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - mxRecords?: string[]; + /** @enum {string} */ + spfStatus: "VERIFIED" | "FAILED" | "NOT_CHECKED"; + /** @description Raw SES DKIM verification status, e.g. `Success` or `Pending`. */ + status: string; + /** @description DKIM tokens SES still has to report. Absent once verification has resolved. */ + tokens?: string[]; verified: boolean; }; /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ @@ -4216,6 +4243,11 @@ export interface components { projectId: string; /** @enum {string} */ reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** + * @description How far the suppression reaches. `PROJECT` is every record this API creates or returns today. + * @enum {string} + */ + scope: "PROJECT" | "GLOBAL"; /** @enum {string} */ source: "SES_WEBHOOK" | "API" | "DASHBOARD"; }; @@ -4232,14 +4264,11 @@ export interface components { source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; suppressed: boolean; }; - /** @description Cursor-paginated list of suppressions. */ + /** @description Cursor-paginated list of suppressions. NOTE: this route answers a bare body — there is no `{success, data}` envelope. */ SuppressionListResponse: { - cursor?: string | null; - data: components["schemas"]["Suppression"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; + items: components["schemas"]["Suppression"][]; + /** @description Cursor for the next page, or `null` on the last page. Never omitted. */ + nextCursor: string | null; }; /** @description A suppressed address as exposed on the v1 API. */ SuppressionV1: { @@ -4281,6 +4310,8 @@ export interface components { * @description ISO 8601 datetime string */ createdAt: string; + /** @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'. */ + currentVersion: number; description?: string | null; /** @enum {string} */ emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; @@ -4556,7 +4587,7 @@ export interface components { /** @enum {boolean} */ success: true; }; - /** @description A user-managed outbound webhook. */ + /** @description A user-managed outbound webhook. Never carries a secret. */ Webhook: { consecutiveFailures: number; /** @@ -4569,10 +4600,16 @@ export interface components { * @description ISO 8601 datetime string */ disabledAt?: string | null; + /** @description Sending domains this endpoint is scoped to. Empty means every domain on the project. */ + domains: string[]; eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; /** Format: uuid */ id: string; - lastFour?: string; + /** + * Format: date-time + * @description While a rotation is in flight, when the OLD secret stops being accepted. `null` outside a rotation. + */ + previousSecretExpiresAt?: string | null; /** Format: uuid */ projectId: string; /** @enum {string} */ @@ -4617,10 +4654,10 @@ export interface components { }; /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ WebhookCreateResponse: { - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { + data: { /** @description Plaintext shared secret. Returned ONCE on create. */ secret: string; + webhook: components["schemas"]["Webhook"]; }; /** @enum {boolean} */ success: true; @@ -11240,7 +11277,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description The contact. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -11274,6 +11311,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -12834,7 +12880,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description The list to validate. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -12868,6 +12914,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14257,7 +14312,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14291,6 +14346,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14325,7 +14389,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14363,6 +14427,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14397,7 +14470,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14435,6 +14508,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14534,7 +14616,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description The validation run. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14568,6 +14650,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no validation run with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14608,7 +14699,7 @@ export interface operations { }; header?: never; path: { - /** @description The validation run. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14642,6 +14733,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no validation run with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15375,7 +15475,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15452,7 +15552,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15542,7 +15642,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15632,7 +15732,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15715,7 +15815,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15882,7 +15982,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15963,7 +16063,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16049,7 +16149,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16126,7 +16226,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16206,7 +16306,7 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; From 11af9468b69c1752779a8bb231c8477380e946f4 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 05:39:22 -0400 Subject: [PATCH 4/7] test: stop pinning wire shapes that 57826bad replaced Three fixtures described responses the API no longer sends. Every one of them asserted only a URL or a method, so all three kept passing while standing for a body that had changed underneath them -- and a fixture is the nearest thing this suite has to a written claim about the wire. - The legacy domain record keys the host as `domain`; `name` is gone. - Domain verification reports one status per DNS record type, and `status` is SES's own raw DKIM state (`Success`, `Pending`) rather than a VERIFIED verdict for the whole domain. The new fixture reads two of them back, so a future collapse of the three into one verdict fails here. - GET /api/suppression answers a bare `{items, nextCursor}` with no `{success, data}` envelope, so the fixture carries the body the route returns and the test reads `items` and `nextCursor` off it. --- src/__tests__/domains.test.ts | 42 ++++++++++++++++++++++++++----- src/__tests__/suppression.test.ts | 10 +++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/__tests__/domains.test.ts b/src/__tests__/domains.test.ts index cdd9bef..b0f50bb 100644 --- a/src/__tests__/domains.test.ts +++ b/src/__tests__/domains.test.ts @@ -43,7 +43,7 @@ describe("domains setup hand-off", () => { describe("domains resource", () => { test("create POSTs /api/domains", async () => { const { client, fetchMock } = makeClient(); - fetchMock.mockResolvedValue(jsonResponse(201, { success: true, data: { id: "d_1", name: "mail.example.com" } })); + fetchMock.mockResolvedValue(jsonResponse(201, { success: true, data: { id: "d_1", domain: "mail.example.com" } })); const result = await client.domains.create({ domain: "mail.example.com" }); expect(getCall(fetchMock).url).toBe("http://localhost/api/domains"); expect((result as { id: string }).id).toBe("d_1"); @@ -58,18 +58,48 @@ describe("domains resource", () => { test("verify POSTs /api/domains/{id}/verify", async () => { const { client, fetchMock } = makeClient(); - fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { status: "PENDING" } })); + // `status` is SES's own raw DKIM state; the per-record checks are their own fields. + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { + domain: "mail.example.com", + status: "Pending", + verified: false, + dkimStatus: "PENDING", + spfStatus: "NOT_CHECKED", + dmarcStatus: "NOT_CHECKED", + mailFromDomain: null, + }, + }), + ); await client.domains.verify("d_1"); const { url, init } = getCall(fetchMock); expect(url).toBe("http://localhost/api/domains/d_1/verify"); expect(init.method).toBe("POST"); }); - test("getVerification GETs /api/domains/{id}/verify", async () => { + test("getVerification GETs /api/domains/{id}/verify and reports each record type", async () => { const { client, fetchMock } = makeClient(); - fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { status: "VERIFIED" } })); - await client.domains.getVerification("d_1"); + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { + domain: "mail.example.com", + status: "Success", + verified: true, + dkimStatus: "VERIFIED", + spfStatus: "VERIFIED", + dmarcStatus: "NOT_CHECKED", + mailFromDomain: "bounce.mail.example.com", + }, + }), + ); + const status = await client.domains.getVerification("d_1"); expect(getCall(fetchMock).init.method).toBe("GET"); + // DMARC unchecked while DKIM and SPF pass — one status per record type, not one verdict. + expect(status.dkimStatus).toBe("VERIFIED"); + expect(status.dmarcStatus).toBe("NOT_CHECKED"); }); test("throws SendlyPermissionError on 403", async () => { @@ -87,7 +117,7 @@ describe("domains stream assignment (legacy)", () => { fetchMock.mockResolvedValue( jsonResponse(200, { success: true, - data: { id: "d_1", name: "mail.example.com", stream: "MARKETING", streamDefault: true }, + data: { id: "d_1", domain: "mail.example.com", stream: "MARKETING", streamDefault: true }, }), ); diff --git a/src/__tests__/suppression.test.ts b/src/__tests__/suppression.test.ts index 4aa69df..69bcffc 100644 --- a/src/__tests__/suppression.test.ts +++ b/src/__tests__/suppression.test.ts @@ -25,13 +25,17 @@ describe("suppression resource", () => { expect(getCall(fetchMock).url).toBe("http://localhost/api/suppression"); }); - test("list serializes reason filter", async () => { + test("list serializes reason filter and hands back the bare body", async () => { const { client, fetchMock } = makeClient(); - fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { items: [] } })); - await client.suppression.list({ reason: "MANUAL", limit: 100 }); + // Alone among the legacy reads, this route answers no `{ success, data }` + // envelope — the page IS the body, so there is nothing to unwrap. + fetchMock.mockResolvedValue(jsonResponse(200, { items: [], nextCursor: null })); + const page = await client.suppression.list({ reason: "MANUAL", limit: 100 }); const { url } = getCall(fetchMock); expect(url).toContain("reason=MANUAL"); expect(url).toContain("limit=100"); + expect(page.items).toEqual([]); + expect(page.nextCursor).toBeNull(); }); test("get encodes email path segment", async () => { From 9ae039adc2d66eefe75338706f203160c05f04a7 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 05:39:24 -0400 Subject: [PATCH 5/7] docs(resources): say what the fields 57826bad added are for The types carry these already, because they are generated. What a generated type cannot say is why a caller should look at a field, and four of these are useless without that. - listDmarcReports: `intake_configured` is the field that makes an empty page readable. `data: []` used to mean either "no receiver reported a failure" or "no intake mailbox exists, so nothing can ever arrive", and reporting the second as the first is how a silent misconfiguration gets called healthy. - domains.create/verify: the response carries `dkimTokens` to publish as CNAME records, and one status per record type. `status` is SES's raw DKIM state, the `*Status` trio is our own DNS check, and they can disagree. - suppression.list: alone among the legacy reads it answers no envelope. - templates.update: `currentVersion` moves only when the rendered content does, which is what makes it comparable against the version a campaign sent. - webhooks.create: the endpoint sits beside the secret now, so the id is at `data.webhook.id` rather than `data.id`. --- src/resources/deliverability.ts | 6 ++++++ src/resources/domains.ts | 14 ++++++++++++-- src/resources/suppression.ts | 9 ++++++++- src/resources/templates.ts | 9 ++++++++- src/resources/webhooks.ts | 4 ++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/resources/deliverability.ts b/src/resources/deliverability.ts index 578038f..fe902fb 100644 --- a/src/resources/deliverability.ts +++ b/src/resources/deliverability.ts @@ -85,6 +85,12 @@ export class DeliverabilityResource { * 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 diff --git a/src/resources/domains.ts b/src/resources/domains.ts index cd9972b..97f2873 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -33,7 +33,10 @@ export class DomainsResource { * `eu-west-1`). On the very first domain for a project this also locks the * project's region; subsequent calls must match. * - * The response includes DNS records to set. + * 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. */ async create(body: AddDomainRequest): Promise { const envelope = await this.client.request<{ success: true; data: DomainRecord }>({ @@ -61,7 +64,14 @@ export class DomainsResource { return this.client.unwrap(envelope); } - /** 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. + */ async verify(id: string): Promise { const envelope = await this.client.request<{ success: true; data: DomainVerificationStatus }>({ method: "POST", diff --git a/src/resources/suppression.ts b/src/resources/suppression.ts index 52cb195..140f5f5 100644 --- a/src/resources/suppression.ts +++ b/src/resources/suppression.ts @@ -36,7 +36,14 @@ export class SuppressionResource { return this.client.unwrap(envelope); } - /** 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. + */ async list(query?: ListSuppressionsQuery): Promise { return this.client.request({ method: "GET", diff --git a/src/resources/templates.ts b/src/resources/templates.ts index 7714e58..b520853 100644 --- a/src/resources/templates.ts +++ b/src/resources/templates.ts @@ -54,7 +54,14 @@ export class TemplatesResource { return this.client.unwrap(envelope); } - /** 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". + */ async update(id: string, body: UpdateTemplateRequest): Promise { const envelope = await this.client.request<{ success: true; data: TemplateRecord }>({ method: "PATCH", diff --git a/src/resources/webhooks.ts b/src/resources/webhooks.ts index 8ed72fb..51bc2fa 100644 --- a/src/resources/webhooks.ts +++ b/src/resources/webhooks.ts @@ -40,6 +40,10 @@ export class WebhooksResource { * Create a new outbound webhook subscription. 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`. */ async create(body: CreateWebhookRequest): Promise { return this.client.request({ From e45143ac3e5bf9a51a46a78e11a963d612ac46c1 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 05:39:25 -0400 Subject: [PATCH 6/7] docs: 1.1 is releasable, and the 57826bad delta is written down The release note told a reader to hold 1.1 back until the platform shipped the renamed wire. It has -- 57826bad, deployed 2026-09-06 -- so the note now says that, names the commit the vendored spec came from, and keeps the one warning that still applies: an SDK sending `emailCategory` at a pre-57826bad platform is still answered 422 on every template and campaign write. The changelog gains the five wire changes a 1.0 caller has to act on (the `Domain.name` rename and the dropped `dkim` array, the per-record verification statuses, the enveloped suppression list becoming a bare body, the nested webhook create response, and the dropped `Webhook.lastFour`) and the six additive ones. The README examples now read the fields that exist: DKIM tokens and per-record statuses on a domain, `intake_configured` before anyone calls a DMARC page clean, `items`/`nextCursor` off the suppression list, and `data.webhook.id` out of a create. --- CHANGELOG.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++---- README.md | 48 +++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b01ad28..fdab795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,17 @@ 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. -> **Do not publish or deploy 1.1 before the platform deploy that ships the -> renamed wire has gone out.** These types no longer carry `type`, `data` (on -> the v1 event write) or `mailFromStatus`, and an SDK sending `emailCategory` at -> an API that still expects `type` is answered `422 validation_error` on every -> template and campaign write. The order is: platform deploy first, then publish -> the SDKs. +> **The platform deploy this release waited on has shipped** — monorepo commit +> `57826bad`, deployed 2026-09-06 — so 1.1 is releasable. The wire now speaks +> `emailCategory`, `payload` on the v1 event write, and `mailFromDomainStatus`, +> which is what these types send. 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 `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.mjs` for why production is never the source. ### Breaking @@ -101,6 +106,38 @@ API that 1.0 did not. 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` array 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 `null` 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 @@ -215,6 +252,40 @@ false })` stops new runs and leaves every in-flight contact walking the `suppression.listAllV1`, `templates.listAllV1`, `topics.listAll`, `validation.listResultsAll` and `webhooks.listAllV1`. +- **`intake_configured` on the DMARC report list**, and it is the field that + makes an empty page readable. `deliverability.listDmarcReports` 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.getV1` 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 `null` 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 answered + `404 resource_not_found` already; the contract now says so, which is what the + generated types and the error-handling examples are read from. + ### Fixed - **README: `emails.list` was destructured wrongly.** The example read diff --git a/README.md b/README.md index f105a6a..4c27932 100644 --- a/README.md +++ b/README.md @@ -326,14 +326,27 @@ answered still reads correctly. ```ts const domain = await sendly.domains.create({ domain: "mail.your-domain.com" }); +// Publish each token as a CNAME record before verification can succeed. +console.log(domain.dkimTokens); + await sendly.domains.verify(domain.id); + const status = await sendly.domains.getVerification(domain.id); +// One status per record type, not one verdict for the domain. +console.log(status.dkimStatus, status.spfStatus, status.dmarcStatus); ``` Pass `region` to pin the domain to an SES region (`us-east-1`, `us-west-2` or `eu-west-1`). The first domain locks the project's region; later ones must match it. +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. + Publishing the DNS records by hand is not the only route. `startSetup` opens the guided hand-off and returns the session exactly as the API returns it: @@ -447,6 +460,11 @@ const template = await sendly.templates.create({ (the member that used to be called `HEADLESS`). It defaults to `MARKETING` and 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". + 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`: @@ -587,11 +605,23 @@ day. ```ts const reports = await sendly.deliverability.listDmarcReports({ 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 (!reports.intake_configured) { + console.warn("DMARC report intake is not configured on this deployment"); +} + for (const report of reports.data) { console.log(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. @@ -603,9 +633,15 @@ const created = await sendly.webhooks.create({ url: "https://your-app.com/webhooks/sendly", eventTypes: ["email.delivered", "email.bounced", "email.complained"], }); -// store `created.data.secret` securely — used to verify HMAC signatures +// store `created.data.secret` securely — used to verify HMAC signatures. +// The endpoint is beside it rather than spread around it: `created.data.webhook.id`. ``` +A webhook record carries `domains` — the sending domains this endpoint is scoped +to, where an empty array 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. + On v1 the same registration resolves the secret beside the webhook, and adds rotation: @@ -630,8 +666,18 @@ dropping an event. ```ts await sendly.suppression.add({ email: "angry@example.com", reason: "MANUAL" }); + +// Alone among the legacy reads, this one answers no `{ success, data }` +// envelope — the page IS the body. +const page = await sendly.suppression.list({ reason: "MANUAL", limit: 100 }); +for (const record of page.items) { + console.log(record.email, record.reason, record.scope); +} ``` +`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`, From 1157d40f5514df6949e4e51db0990d5f0a8ab1c0 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 05:39:28 -0400 Subject: [PATCH 7/7] build: rebuild dist against the 57826bad types dist is tracked, so leaving it behind the regenerated types would ship a declaration file describing the pre-57826bad wire to anyone reading the package rather than the source. `pnpm build` over the new spec, no other change; release.yml rebuilds it from scratch before publishing anyway. --- dist/index.cjs | 42 ++++++++- dist/index.d.cts | 224 +++++++++++++++++++++++++++++++++++++---------- dist/index.d.ts | 224 +++++++++++++++++++++++++++++++++++++---------- dist/index.js | 42 ++++++++- 4 files changed, 434 insertions(+), 98 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 3c276fa..579201b 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -484,6 +484,12 @@ var DeliverabilityResource = class { * 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 @@ -515,7 +521,10 @@ var DomainsResource = class { * `eu-west-1`). On the very first domain for a project this also locks the * project's region; subsequent calls must match. * - * The response includes DNS records to set. + * 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. */ async create(body) { const envelope = await this.client.request({ @@ -540,7 +549,14 @@ var DomainsResource = class { }); return this.client.unwrap(envelope); } - /** 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. + */ async verify(id) { const envelope = await this.client.request({ method: "POST", @@ -1272,7 +1288,14 @@ var SuppressionResource = class { }); return this.client.unwrap(envelope); } - /** 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. + */ async list(query) { return this.client.request({ method: "GET", @@ -1392,7 +1415,14 @@ var TemplatesResource = class { }); return this.client.unwrap(envelope); } - /** 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". + */ async update(id, body) { const envelope = await this.client.request({ method: "PATCH", @@ -1702,6 +1732,10 @@ var WebhooksResource = class { * Create a new outbound webhook subscription. 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`. */ async create(body) { return this.client.request({ diff --git a/dist/index.d.cts b/dist/index.d.cts index a030b19..688d9a7 100644 --- a/dist/index.d.cts +++ b/dist/index.d.cts @@ -3281,6 +3281,8 @@ interface components { DmarcReportV1List: { data: components["schemas"]["DmarcReportV1"][]; has_more: boolean; + /** @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. */ + intake_configured: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; @@ -3293,13 +3295,27 @@ interface components { createdAt: string; /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ defaultFromAddress?: string | null; - dkim?: { - name: string; - type: string; - value: string; - }[]; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + dkimStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description SES DKIM tokens to publish as CNAME records before the domain can verify. */ + dkimTokens?: string[] | null; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + dmarcStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description The bare domain, e.g. `mail.acme.com`. */ + domain: string; /** Format: uuid */ id: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + lastHealthCheckAt?: string | null; /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ mailFromDomain?: string | null; /** @@ -3307,10 +3323,16 @@ interface components { * @enum {string|null} */ mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - name: string; /** Format: uuid */ projectId: string; + /** @description Whether inbound mail for this domain is routed to Sendly mailboxes. */ + receivingEnabled: boolean; region?: string | null; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + spfStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; /** * @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 {string|null} @@ -3378,18 +3400,23 @@ interface components { }; /** @description Outcome of a verification check against SES. */ DomainVerificationStatus: { - dkim?: { - name: string; - type: string; - value: string; - }[]; - mailFromDomain?: string | null; + /** @enum {string} */ + dkimStatus: "VERIFIED" | "PENDING" | "FAILED"; + /** @enum {string} */ + dmarcStatus: "VERIFIED" | "FAILED" | "NOT_CHECKED"; + domain: string; + mailFromDomain: string | null; /** * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. * @enum {string|null} */ mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - mxRecords?: string[]; + /** @enum {string} */ + spfStatus: "VERIFIED" | "FAILED" | "NOT_CHECKED"; + /** @description Raw SES DKIM verification status, e.g. `Success` or `Pending`. */ + status: string; + /** @description DKIM tokens SES still has to report. Absent once verification has resolved. */ + tokens?: string[]; verified: boolean; }; /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ @@ -4214,6 +4241,11 @@ interface components { projectId: string; /** @enum {string} */ reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** + * @description How far the suppression reaches. `PROJECT` is every record this API creates or returns today. + * @enum {string} + */ + scope: "PROJECT" | "GLOBAL"; /** @enum {string} */ source: "SES_WEBHOOK" | "API" | "DASHBOARD"; }; @@ -4230,14 +4262,11 @@ interface components { source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; suppressed: boolean; }; - /** @description Cursor-paginated list of suppressions. */ + /** @description Cursor-paginated list of suppressions. NOTE: this route answers a bare body — there is no `{success, data}` envelope. */ SuppressionListResponse: { - cursor?: string | null; - data: components["schemas"]["Suppression"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; + items: components["schemas"]["Suppression"][]; + /** @description Cursor for the next page, or `null` on the last page. Never omitted. */ + nextCursor: string | null; }; /** @description A suppressed address as exposed on the v1 API. */ SuppressionV1: { @@ -4279,6 +4308,8 @@ interface components { * @description ISO 8601 datetime string */ createdAt: string; + /** @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'. */ + currentVersion: number; description?: string | null; /** @enum {string} */ emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; @@ -4554,7 +4585,7 @@ interface components { /** @enum {boolean} */ success: true; }; - /** @description A user-managed outbound webhook. */ + /** @description A user-managed outbound webhook. Never carries a secret. */ Webhook: { consecutiveFailures: number; /** @@ -4567,10 +4598,16 @@ interface components { * @description ISO 8601 datetime string */ disabledAt?: string | null; + /** @description Sending domains this endpoint is scoped to. Empty means every domain on the project. */ + domains: string[]; eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; /** Format: uuid */ id: string; - lastFour?: string; + /** + * Format: date-time + * @description While a rotation is in flight, when the OLD secret stops being accepted. `null` outside a rotation. + */ + previousSecretExpiresAt?: string | null; /** Format: uuid */ projectId: string; /** @enum {string} */ @@ -4615,10 +4652,10 @@ interface components { }; /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ WebhookCreateResponse: { - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { + data: { /** @description Plaintext shared secret. Returned ONCE on create. */ secret: string; + webhook: components["schemas"]["Webhook"]; }; /** @enum {boolean} */ success: true; @@ -11237,7 +11274,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The contact. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -11271,6 +11308,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -12831,7 +12877,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The list to validate. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -12865,6 +12911,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14254,7 +14309,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14288,6 +14343,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14322,7 +14386,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14360,6 +14424,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14394,7 +14467,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14432,6 +14505,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14531,7 +14613,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The validation run. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14565,6 +14647,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no validation run with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14605,7 +14696,7 @@ interface operations { }; header?: never; path: { - /** @description The validation run. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14639,6 +14730,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no validation run with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15372,7 +15472,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15449,7 +15549,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15539,7 +15639,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15629,7 +15729,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15712,7 +15812,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15879,7 +15979,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15960,7 +16060,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16046,7 +16146,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16123,7 +16223,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16203,7 +16303,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -17300,6 +17400,12 @@ declare class DeliverabilityResource { * 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 @@ -17329,14 +17435,24 @@ declare class DomainsResource { * `eu-west-1`). On the very first domain for a project this also locks the * project's region; subsequent calls must match. * - * The response includes DNS records to set. + * 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. */ create(body: AddDomainRequest): Promise; /** List all domains for the project. */ list(): Promise; /** Fetch a single domain. */ get(id: string): Promise; - /** 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. + */ verify(id: string): Promise; /** Read current SES verification status for a domain. */ getVerification(id: string): Promise; @@ -17817,7 +17933,14 @@ declare class SuppressionResource { constructor(client: Sendly); /** Add an email to the project suppression list. */ add(body: AddSuppressionRequest): Promise; - /** 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. + */ list(query?: ListSuppressionsQuery): Promise; /** Check whether a given email is suppressed. */ get(email: string): Promise; @@ -17883,7 +18006,14 @@ declare class TemplatesResource { list(query?: ListTemplatesQuery): Promise; /** Fetch a single template by id. */ get(id: string): Promise; - /** 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". + */ update(id: string, body: UpdateTemplateRequest): Promise; /** Delete a template. The API answers 200 with `{ success, data: { id } }` (409 if still referenced); the SDK resolves void. */ delete(id: string): Promise; @@ -18105,6 +18235,10 @@ declare class WebhooksResource { * Create a new outbound webhook subscription. 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`. */ create(body: CreateWebhookRequest): Promise; /** List all webhooks for the project. */ diff --git a/dist/index.d.ts b/dist/index.d.ts index a030b19..688d9a7 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -3281,6 +3281,8 @@ interface components { DmarcReportV1List: { data: components["schemas"]["DmarcReportV1"][]; has_more: boolean; + /** @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. */ + intake_configured: boolean; /** @description Pass as `after` to fetch the next page. `null` on the last page. */ next_cursor: string | null; }; @@ -3293,13 +3295,27 @@ interface components { createdAt: string; /** @description The address a send on this stream uses when it names none. Always on this identity's own host. */ defaultFromAddress?: string | null; - dkim?: { - name: string; - type: string; - value: string; - }[]; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + dkimStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description SES DKIM tokens to publish as CNAME records before the domain can verify. */ + dkimTokens?: string[] | null; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + dmarcStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; + /** @description The bare domain, e.g. `mail.acme.com`. */ + domain: string; /** Format: uuid */ id: string; + /** + * Format: date-time + * @description ISO 8601 datetime string + */ + lastHealthCheckAt?: string | null; /** @description Custom MAIL FROM subdomain SES has on record (normally `sendly.`). */ mailFromDomain?: string | null; /** @@ -3307,10 +3323,16 @@ interface components { * @enum {string|null} */ mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - name: string; /** Format: uuid */ projectId: string; + /** @description Whether inbound mail for this domain is routed to Sendly mailboxes. */ + receivingEnabled: boolean; region?: string | null; + /** + * @description Result of the last DNS check for this record type. + * @enum {string|null} + */ + spfStatus?: "NOT_CHECKED" | "PENDING" | "VERIFIED" | "FAILED" | null; /** * @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 {string|null} @@ -3378,18 +3400,23 @@ interface components { }; /** @description Outcome of a verification check against SES. */ DomainVerificationStatus: { - dkim?: { - name: string; - type: string; - value: string; - }[]; - mailFromDomain?: string | null; + /** @enum {string} */ + dkimStatus: "VERIFIED" | "PENDING" | "FAILED"; + /** @enum {string} */ + dmarcStatus: "VERIFIED" | "FAILED" | "NOT_CHECKED"; + domain: string; + mailFromDomain: string | null; /** * @description SES custom MAIL FROM setup state. Only `Success` means SES is using it. * @enum {string|null} */ mailFromDomainStatus?: "Pending" | "Success" | "Failed" | "TemporaryFailure" | "NotConfigured" | null; - mxRecords?: string[]; + /** @enum {string} */ + spfStatus: "VERIFIED" | "FAILED" | "NOT_CHECKED"; + /** @description Raw SES DKIM verification status, e.g. `Success` or `Pending`. */ + status: string; + /** @description DKIM tokens SES still has to report. Absent once verification has resolved. */ + tokens?: string[]; verified: boolean; }; /** @description Body for POST /api/mailboxes/{id}/drafts — ask for help writing, never for sending. */ @@ -4214,6 +4241,11 @@ interface components { projectId: string; /** @enum {string} */ reason: "HARD_BOUNCE" | "COMPLAINT" | "MANUAL" | "UNSUBSCRIBE"; + /** + * @description How far the suppression reaches. `PROJECT` is every record this API creates or returns today. + * @enum {string} + */ + scope: "PROJECT" | "GLOBAL"; /** @enum {string} */ source: "SES_WEBHOOK" | "API" | "DASHBOARD"; }; @@ -4230,14 +4262,11 @@ interface components { source?: "SES_WEBHOOK" | "API" | "DASHBOARD"; suppressed: boolean; }; - /** @description Cursor-paginated list of suppressions. */ + /** @description Cursor-paginated list of suppressions. NOTE: this route answers a bare body — there is no `{success, data}` envelope. */ SuppressionListResponse: { - cursor?: string | null; - data: components["schemas"]["Suppression"][]; - hasMore?: boolean; - nextCursor?: string | null; - /** @enum {boolean} */ - success: true; + items: components["schemas"]["Suppression"][]; + /** @description Cursor for the next page, or `null` on the last page. Never omitted. */ + nextCursor: string | null; }; /** @description A suppressed address as exposed on the v1 API. */ SuppressionV1: { @@ -4279,6 +4308,8 @@ interface components { * @description ISO 8601 datetime string */ createdAt: string; + /** @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'. */ + currentVersion: number; description?: string | null; /** @enum {string} */ emailCategory: "MARKETING" | "TRANSACTIONAL" | "SELF_MANAGED_UNSUBSCRIBE"; @@ -4554,7 +4585,7 @@ interface components { /** @enum {boolean} */ success: true; }; - /** @description A user-managed outbound webhook. */ + /** @description A user-managed outbound webhook. Never carries a secret. */ Webhook: { consecutiveFailures: number; /** @@ -4567,10 +4598,16 @@ interface components { * @description ISO 8601 datetime string */ disabledAt?: string | null; + /** @description Sending domains this endpoint is scoped to. Empty means every domain on the project. */ + domains: string[]; eventTypes: ("email.sent" | "email.delivered" | "email.opened" | "email.clicked" | "email.bounced" | "email.complained" | "email.failed" | "contact.created" | "contact.unsubscribed" | "contacts.bulk_created")[]; /** Format: uuid */ id: string; - lastFour?: string; + /** + * Format: date-time + * @description While a rotation is in flight, when the OLD secret stops being accepted. `null` outside a rotation. + */ + previousSecretExpiresAt?: string | null; /** Format: uuid */ projectId: string; /** @enum {string} */ @@ -4615,10 +4652,10 @@ interface components { }; /** @description Result of POST /api/webhooks. `secret` is the only time the plaintext is returned — store it securely. */ WebhookCreateResponse: { - /** @description A user-managed outbound webhook. */ - data: components["schemas"]["Webhook"] & { + data: { /** @description Plaintext shared secret. Returned ONCE on create. */ secret: string; + webhook: components["schemas"]["Webhook"]; }; /** @enum {boolean} */ success: true; @@ -11237,7 +11274,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The contact. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -11271,6 +11308,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no contact with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -12831,7 +12877,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The list to validate. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -12865,6 +12911,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no list with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14254,7 +14309,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14288,6 +14343,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14322,7 +14386,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14360,6 +14424,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14394,7 +14467,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The topic. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14432,6 +14505,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no topic with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14531,7 +14613,7 @@ interface operations { query?: never; header?: never; path: { - /** @description The validation run. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14565,6 +14647,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no validation run with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -14605,7 +14696,7 @@ interface operations { }; header?: never; path: { - /** @description The validation run. */ + /** @description Resource id. */ id: string; }; cookie?: never; @@ -14639,6 +14730,15 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description `resource_not_found` — no validation run with this id belongs to the authenticated project. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description `validation_error` — query, path, or body parameters did not match the schema. */ 422: { headers: { @@ -15372,7 +15472,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15449,7 +15549,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15539,7 +15639,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15629,7 +15729,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15712,7 +15812,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15879,7 +15979,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -15960,7 +16060,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16046,7 +16146,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16123,7 +16223,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -16203,7 +16303,7 @@ interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @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. */ 404: { headers: { [name: string]: unknown; @@ -17300,6 +17400,12 @@ declare class DeliverabilityResource { * 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 @@ -17329,14 +17435,24 @@ declare class DomainsResource { * `eu-west-1`). On the very first domain for a project this also locks the * project's region; subsequent calls must match. * - * The response includes DNS records to set. + * 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. */ create(body: AddDomainRequest): Promise; /** List all domains for the project. */ list(): Promise; /** Fetch a single domain. */ get(id: string): Promise; - /** 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. + */ verify(id: string): Promise; /** Read current SES verification status for a domain. */ getVerification(id: string): Promise; @@ -17817,7 +17933,14 @@ declare class SuppressionResource { constructor(client: Sendly); /** Add an email to the project suppression list. */ add(body: AddSuppressionRequest): Promise; - /** 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. + */ list(query?: ListSuppressionsQuery): Promise; /** Check whether a given email is suppressed. */ get(email: string): Promise; @@ -17883,7 +18006,14 @@ declare class TemplatesResource { list(query?: ListTemplatesQuery): Promise; /** Fetch a single template by id. */ get(id: string): Promise; - /** 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". + */ update(id: string, body: UpdateTemplateRequest): Promise; /** Delete a template. The API answers 200 with `{ success, data: { id } }` (409 if still referenced); the SDK resolves void. */ delete(id: string): Promise; @@ -18105,6 +18235,10 @@ declare class WebhooksResource { * Create a new outbound webhook subscription. 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`. */ create(body: CreateWebhookRequest): Promise; /** List all webhooks for the project. */ diff --git a/dist/index.js b/dist/index.js index 2c42ed9..7b888ba 100644 --- a/dist/index.js +++ b/dist/index.js @@ -422,6 +422,12 @@ var DeliverabilityResource = class { * 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 @@ -453,7 +459,10 @@ var DomainsResource = class { * `eu-west-1`). On the very first domain for a project this also locks the * project's region; subsequent calls must match. * - * The response includes DNS records to set. + * 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. */ async create(body) { const envelope = await this.client.request({ @@ -478,7 +487,14 @@ var DomainsResource = class { }); return this.client.unwrap(envelope); } - /** 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. + */ async verify(id) { const envelope = await this.client.request({ method: "POST", @@ -1210,7 +1226,14 @@ var SuppressionResource = class { }); return this.client.unwrap(envelope); } - /** 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. + */ async list(query) { return this.client.request({ method: "GET", @@ -1330,7 +1353,14 @@ var TemplatesResource = class { }); return this.client.unwrap(envelope); } - /** 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". + */ async update(id, body) { const envelope = await this.client.request({ method: "PATCH", @@ -1640,6 +1670,10 @@ var WebhooksResource = class { * Create a new outbound webhook subscription. 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`. */ async create(body) { return this.client.request({