openapi: 3.1.0
info:
  title: oToK REST API
  version: "1.6.0"
  summary: Public REST API for the oToK multichannel marketing communication platform.
  description: |
    The oToK REST API (`/v1`) gives programmatic access to your workspace: contacts (including
    per-channel marketing consent), tags and groups, WhatsApp campaigns and templates, deals
    and pipelines, the product catalog, payments, payment requests (pay-links), orders,
    transactional email, broadcast email campaigns, newsletters, email suppressions,
    read-only audience and sender-profile discovery, outbound webhooks, and bookings.

    ## Authentication

    Send a workspace API key as a bearer token on every request:

    ```
    Authorization: Bearer otok_live_...
    ```

    Keys are created in **Settings → Developers** in the oToK app and are shown exactly once.
    A revoked key is indistinguishable from an unknown key — both return `401 "Invalid API key"`.
    The REST API requires a plan with API access (**Growth or higher**); other plans receive `403`.

    Endpoint groups that mirror a plan-gated product area additionally require that feature on
    the workspace's plan — deals/pipelines (`deals`), payments and contact documents
    (`payments`), payment requests (`workspace_payments`), orders (`orders`), campaigns
    (`campaigns`), email campaigns (`email_marketing`), newsletters (`newsletters`),
    bookings/meeting-types (`booking`). Every route in a gated group answers
    `403` with `error_code: "FEATURE_NOT_INCLUDED_IN_PLAN"` (a body **without** a `statusCode`
    field) when the plan lacks the feature. Note the two distinct payment gates:
    `/v1/payments*` and `GET /v1/contacts/{id}/documents` require the **`payments`** feature
    (the payments ledger), while `/v1/payment-requests*` requires **`workspace_payments`**
    (collecting money through the workspace's own connected payment provider) — a workspace
    can hold either feature without the other, and the 403 message embeds whichever feature
    id is missing. The suppression list (`/v1/suppressions*`) and the sender-profile
    listing (`GET /v1/sender-profiles`) require the **`email_marketing`** feature.
    Contacts (including consent), notes, tags, contact groups, templates, products,
    audiences, emails, and webhook endpoints require only plan-wide API access.

    ## Error shapes

    The API uses **two error body shapes**:

    1. **Domain errors** (email, email-campaign, newsletter, webhook-endpoint, consent,
       suppression, and product APIs, and `POST /v1/campaigns/{id}/execute` failures) use a
       structured envelope: `{"error": {"code", "message"}}` — key on `error.code`.
    2. **Framework errors** (validation, auth, rate limiting, most resource errors) use
       `{"statusCode", "message", "error"}`, where `message` is a string or an array of
       per-field validation messages. Some 4xx responses extend this shape with a
       machine-readable `error_code` field (deals, payments, orders, bookings, contacts).

    Request bodies are strictly validated: unknown body properties are rejected with 400.
    Path `:id` parameters must be UUIDs; a non-UUID id returns 400 (except
    `DELETE /v1/webhook-endpoints/{id}`, which returns 404).

    ## Deletion

    The API never deletes customer data — contacts, deals, products, payments, orders,
    campaigns, tags, and contact groups have no DELETE routes (deactivate a product with
    `is_active: false` instead). The only DELETE endpoints are for resources the API
    owns: `DELETE /v1/notes/{id}`, `DELETE /v1/webhook-endpoints/{id}`,
    `DELETE /v1/suppressions/{id}` (which lifts a send-time block — it deletes no
    contact data and resubscribes no one), and `DELETE /v1/newsletter-issues/{id}`
    (drafts and scheduled issues only — published issues are never deletable).

    ## Rate limits

    | Scope | Limit |
    |---|---|
    | Per API key (default, all endpoints) | 100 requests / minute |
    | `POST /v1/emails` | 300 requests / minute per key |
    | Per source IP, across all API-key traffic | 300 requests / minute |

    Exceeding a limit returns HTTP 429 with a `Retry-After` header. The per-key bucket is
    shared across all source IPs using that key.

    ## Pagination

    Most list endpoints return `{ "data", "total", "limit", "offset" }` with `limit`
    default 50 (hard cap 500). `GET /v1/deals`, `GET /v1/payments`,
    `GET /v1/payment-requests`, `GET /v1/orders`, and the
    email-campaign/newsletter/audience/sender-profile lists use dedicated query
    parameters with `limit` default 25 (cap 100); absent or
    empty `limit`/`offset` values default. On deals, payments, payment requests, and
    the email-campaign/newsletter/audience/sender-profile lists, malformed values
    return `400`
    (`"Invalid limit: must be a non-negative integer"`); on orders, paging **never**
    returns 400 — malformed or zero values silently default and out-of-range values are
    clamped. Unrecognized enum filter values on deals/payments/orders are silently
    ignored; `GET /v1/payment-requests`, `GET /v1/email-campaigns`, and
    `GET /v1/newsletters/{id}/issues` instead reject an unknown `status` with 400
    (as does `GET /v1/audiences` for an unknown `kind`).

    ## Idempotency

    - Contacts upsert by `phone`/`email`.
    - Deals, payments, and orders upsert by `external_reference`; products upsert by
      `external_id`.
    - Email campaigns and newsletter issues upsert by `external_reference` — a replay
      updates content/fields while the record is still editable and never touches
      `status`, `scheduled_at`, or an issue's number.
    - Suppression adds are idempotent per address (re-adding answers `duplicate: true`).
    - Order refunds are idempotent per `external_refund_id` — only when you supply one.
    - Emails require an explicit `idempotency_key`.
    - Booking creation is idempotent per slot + meeting type + contact (server-derived, no key).
    - **Payment requests have NO idempotency key** — `POST /v1/payment-requests` is **not**
      idempotent: a repeat POST mints a second, independently payable link. Cancel extras
      with `POST /v1/payment-requests/{id}/cancel`.

    Idempotent create responses carry a top-level boolean `duplicate` field — `false` on a
    fresh create, `true` when the call matched an existing record — with one exception:
    `POST /v1/orders` returns the same full order body for both outcomes and **no
    `duplicate` marker** (compare `created_at`, or pre-check with
    `GET /v1/orders?external_reference=...`); the order **refund** endpoint does return
    `{ "duplicate", "order" }`. Status codes are 201 for both outcomes everywhere except
    `POST /v1/emails`, which answers 201 fresh / 200 replay.

    ## Changes in 1.6.0

    - New **Email campaigns** resource group (`/v1/email-campaigns*`) — author, estimate,
      launch, and schedule broadcast email campaigns: idempotent create via
      `external_reference`, PATCH while draft/scheduled, `GET …/estimate` through the
      send path's audience resolver, and `send`/`schedule`/`unschedule` riding the exact
      in-app launch gates. Requires the `email_marketing` plan feature. A/B testing is
      deliberately not exposed — create A/B campaigns in-app.
    - New **Newsletters** resource group (`/v1/newsletters*` + `/v1/newsletter-issues*`) —
      list/create newsletters (rows carry active subscriber counts) and author their
      issues: idempotent issue create via `external_reference`,
      publish/schedule/unschedule with publish-time issue numbering, and deletion for
      never-published issues. Requires the `newsletters` plan feature.
    - Shared **`content` authoring contract** on both new surfaces: exactly one of
      `markdown` (CommonMark subset + `::button[Label](https://url)` /
      `::snippet[name-or-uuid]` directives + `[[…]]` personalization tokens), typed
      `blocks`, or a raw `design_json` document — compiled at write time, with the
      result reported in the response's `compile: {ok, errors, warnings}` envelope.
    - Two new read-only **targeting-selector discovery** endpoints: `GET /v1/audiences`
      lists the saved audiences campaigns accept as `audience_id` (rows carry the
      summary columns and advisory size cache — never the stored `definition`), and
      `GET /v1/sender-profiles` lists the email from-identities email campaigns accept
      as `sender_profile_id`, each with a `verified` send-readiness signal (requires
      the `email_marketing` plan feature; audiences need only plan-wide API access).
      Neither resource is writable through the public API.

    ## Changes in 1.5.0

    - New `GET /v1/meeting-types/{id}/embed` — website-embed material for a meeting type:
      the hosted booking page URL, the workspace's publishable embed key (`bk_…`, safe in
      page HTML — not the secret API key), and a ready-to-paste snippet. Requires the
      `booking` plan feature.

    ## Changes in 1.4.0

    - New **consent** endpoints on contacts: `GET /v1/contacts/{id}/consent` reads both
      channels' stored consent decision + provider-owned deliverability (email adds the
      composed send-time `suppressed` verdict), and `PUT /v1/contacts/{id}/consent/{channel}`
      records a subscribed/unsubscribed decision with its provenance (evidence trail
      included; a channel with a spam complaint on record answers 409
      `consent_sticky_complained`).
    - New **Suppressions** resource group (`/v1/suppressions*`) — list, idempotently add,
      and remove workspace email-suppression rows. Requires the `email_marketing` plan
      feature. Suppression and consent deliberately compose at send time: adding a
      suppression does not change consent, and removing one resubscribes no one.
    - New **Products** resource group (`/v1/products*`) — the workspace product catalog
      shared by deals and payments: list/get/create/update with an **idempotent POST
      upsert via `external_id`** and no DELETE (deactivate with `is_active: false`).
    - **Fifteen new webhook event types** across six opt-in families, riding the existing
      endpoint registry, signing, and retry policy: contact lifecycle + consent
      (`contact.created`, `contact.updated`, `contact.deleted`, `contact.consent_changed`),
      inbound messages (`message.received`), deal lifecycle (`deal.created`,
      `deal.stage_changed`, `deal.won`, `deal.lost`), booking lifecycle (`booking.created`,
      `booking.rescheduled`, `booking.cancelled`, `booking.reassigned`), event attendance
      (`event.attendance.changed`), and form submissions (`form.submitted`). All are
      opt-in by listing — the default subscription is unchanged, and a pre-existing
      endpoint never starts receiving a new family unasked.

    ## Changes in 1.3.0

    - New **Payment requests** resource group (`/v1/payment-requests*`) — hosted pay-links
      collected through the workspace's own connected payment provider (Cardcom / Sumit) —
      and the four `payment_request.*` webhook event types (opt-in by listing, like the
      order events). Gated by the `workspace_payments` plan feature (distinct from the
      `payments` ledger gate).
    - New `GET /v1/contacts/{id}/documents` — read-only listing of a contact's financial
      documents (invoices, receipts, credit documents), with an opt-in `?live=true`
      provider lookup. Gated by the `payments` feature.
    - Payments: `POST /v1/payments` and `PATCH /v1/payments/{id}` accept a `vat_mode` +
      `vat_rate` pair (**recurring plans only**, always together) and a free-form
      `metadata` object (≤ 2048 bytes serialized; replace-on-write, `null` clears on
      PATCH). Payment and payment-entry response fields are now fully documented,
      including the recurring auto-charging/dunning fields, the stored VAT pair, and
      entry-level refund/credit-document fields.

    ## Changes in 1.2.0

    - New **Orders** resource group (`/v1/orders*`) and the five `order.*` webhook
      event types.
    - **Schema correction** for existing consumers: deal and payment response money —
      `Deal.amount`, `Payment.total_amount`, and payment entry `amount`/`recognized_amount` —
      is now typed as a JSON **number**. Spec versions up to 1.1.0 incorrectly documented
      these response fields as decimal strings (e.g. `"3600.00"`); the API has always
      returned JSON numbers (e.g. `3600`), so nothing changed on the wire — but clients
      generated from the earlier schema will see these declared types change from
      `string` to `number` on regeneration.
  contact:
    name: oToK
    url: https://app.otok.io
  license:
    name: MIT
    url: https://github.com/slikkdev/otok-api/blob/main/LICENSE
externalDocs:
  description: Prose guides (getting started, per-resource pages)
  url: https://slikkdev.github.io/otok-api/
servers:
  - url: https://app.otok.io/api
security:
  - bearerAuth: []
tags:
  - name: Contacts
    description: Contact upsert & update, plus the merge-conflict contract. There is no DELETE endpoint for contacts.
  - name: Contact notes
    description: Plain-text notes on a contact. API note payloads are text only.
  - name: Tags
    description: Tag CRUD (no delete). Names are unique per workspace (case-insensitive).
  - name: Contact groups
    description: Contact group CRUD (no delete). Names are unique per workspace (case-insensitive).
  - name: Campaigns
    description: WhatsApp campaigns — create, schedule, execute.
  - name: Templates
    description: WhatsApp message templates — read and send only; template management is in-app.
  - name: Pipelines
    description: Read pipelines and their stages.
  - name: Deals
    description: Deal CRUD-minus-delete, stage moves, win/lose, idempotent upsert via `external_reference`.
  - name: Products
    description: "The workspace product catalog shared by deals and payments — list, get, create (idempotent upsert via `external_id`), update. No DELETE; deactivate with `is_active: false`."
  - name: Payments
    description: One-time / recurring / installment payments, entries, refunds.
  - name: Payment requests
    description: Hosted pay-by-link requests collected through the workspace's own connected payment provider (Cardcom / Sumit). Mint, list, get, cancel — **no idempotency key**; a repeat POST mints a second link. Requires the `workspace_payments` feature.
  - name: Contact documents
    description: Read-only listing of a contact's financial documents (invoices, receipts, credit documents), aggregated from stored document pointers with an opt-in live provider lookup. Requires the `payments` feature.
  - name: Orders
    description: E-commerce-grade orders on contacts — idempotent create via `external_reference`, refund ledger, mark-paid, cancel. No update or delete routes.
  - name: Emails
    description: "`POST /v1/emails` — idempotent raw transactional sends with opt-in tracking."
  - name: Suppressions
    description: The workspace's email suppression list — a send-time overlay deliberately separate from consent. List, idempotent add, remove. Requires the `email_marketing` feature.
  - name: Audiences
    description: Read-only discovery of the workspace's saved audiences — the reusable targeting selectors campaigns and email campaigns accept as `audience_id`. Rows never include the stored definition; audiences are managed in-app and are not writable through the API.
  - name: Sender profiles
    description: Read-only discovery of the workspace's email from-identities — the selectors email campaigns accept as `sender_profile_id`, each with its sending domain's verification status. Managed in-app; requires the `email_marketing` feature.
  - name: Email campaigns
    description: Broadcast email campaigns — author (`markdown` / `blocks` / `design_json`), estimate, launch, schedule. Idempotent create via `external_reference`. Requires the `email_marketing` feature.
  - name: Newsletters
    description: Smart newsletters and their sequenced issues — create, author, publish, schedule. Idempotent issue create via `external_reference`; issue numbers are assigned at publish. Requires the `newsletters` feature.
  - name: Webhook endpoints
    description: Register receivers for outbound events — email delivery/engagement events on API-originated sends, plus the opt-in `order.*`, `payment_request.*`, `contact.*`, `message.received`, `deal.*`, `booking.*`, `event.attendance.changed`, and `form.submitted` families.
  - name: Meeting types
    description: Read meeting types and query open slots.
  - name: Bookings
    description: Booking lifecycle — create, cancel, reschedule, reassign host.

paths:
  # ───────────────────────────── Contacts ─────────────────────────────
  /v1/contacts:
    get:
      operationId: listContacts
      tags: [Contacts]
      summary: List contacts
      description: |
        Standard list conventions apply. `search` matches `name`, `first_name`, `last_name`,
        `email`, `phone`, `company_name` (case-insensitive substring), plus an exact match on
        the E.164-normalized form of the term and the contact's historical phone/email
        identifiers.

        Contacts support extra filter operators: `$jsonb_contains` for `tags`/`groups` (by
        UUID), `$event_attendance` (`{"event_id": "...", "status": "attended"}`), and the
        virtual consent fields `whatsapp_subscribed`, `email_subscribed` (boolean),
        `whatsapp_deliverability`, `email_deliverability`.
      parameters:
        - $ref: "#/components/parameters/FilterParam"
        - $ref: "#/components/parameters/SortParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
        - $ref: "#/components/parameters/SearchParam"
      responses:
        "200":
          description: Paginated contact list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: upsertContact
      tags: [Contacts]
      summary: Upsert a contact by phone/email
      description: |
        Creates a contact, or updates the existing contact that the given `phone`/`email`
        resolves to. Resolution is history-aware and **phone wins** over email. On a match,
        scalar fields overwrite, `custom_fields` shallow-merge, and `tags`/`groups` are
        **added** to the existing set (never removed by this route).

        The response is **201 in both cases** (create and update) and carries a top-level
        boolean `duplicate` field — `false` when this request created the contact, `true`
        when it matched and updated an existing one.

        If the phone resolves to one existing contact and the email to a *different* one, the
        API does not write: it opens a merge request and returns
        **409 `CONTACT_MERGE_REQUIRED`**. The non-identity fields of the request are parked
        with the merge request and applied when it is resolved.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactWrite"
      responses:
        "201":
          description: |
            The full contact object — returned for both create (`duplicate: false`) and
            update (`duplicate: true`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactUpsertResult"
        "400":
          description: |
            Validation failure, unknown fields, a blacklisted phone
            (`error_code: "PHONE_BLACKLISTED"`), or the workspace contact limit
            (only when a cap is set on the workspace).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/ContactMergeRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/contacts/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getContact
      tags: [Contacts]
      summary: Get one contact
      responses:
        "200":
          description: A single contact object (same enrichment as list rows).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateContact
      tags: [Contacts]
      summary: Update a contact by id
      description: |
        Same field set and validation as the POST upsert (all optional), with different
        semantics:

        - `tags`/`groups` **replace** the full set (empty array clears; names auto-create).
        - `custom_fields` still shallow-merge — set a key to `null` to remove it.
        - `name` and `first_name`/`last_name` stay in sync.
        - `block_state` changes route through the consent/blocking subsystem; global blocks
          cannot be lifted via the API.

        Setting `phone`/`email` to an identifier that belongs to a *different* contact does
        not apply the write: like the POST upsert's conflict path, a merge request is opened
        and the response is **409 `CONTACT_MERGE_REQUIRED`**. Non-identity fields sent in the
        same PATCH are held on the merge request and applied when it is resolved. The
        conflict check is **history-aware**: an identifier no contact currently holds but
        that another contact previously held also parks a merge request and returns the 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactWrite"
      responses:
        "200":
          description: The updated contact object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          description: |
            Validation / unknown fields / non-UUID id, or `error_code: "PHONE_BLACKLISTED"`
            (only when the patch changes the phone to a blacklisted number).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            `error_code: "CONTACT_MERGE_REQUIRED"` — the new phone/email belongs (or, via
            the history-aware check, previously belonged) to another contact. The write is
            not applied; a merge request is opened and the held fields apply when it is
            resolved (merge or dismiss). The response includes `merge_request_id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
              example:
                statusCode: 409
                error: Conflict
                error_code: CONTACT_MERGE_REQUIRED
                merge_request_id: 3a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
                message: "This change would give the contact a phone or email that already belongs to another contact. A merge request was opened — resolve it (merge or dismiss) to apply the change."
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/contacts/{id}/consent:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getContactConsent
      tags: [Contacts]
      summary: Get a contact's per-channel consent
      description: |
        Returns both channels (`whatsapp` + `email`) at once. A channel without a stored
        decision reads `consent_state`/`deliverability` `"unknown"` — treat unknown as not
        sendable. The email channel additionally carries the composed send-time
        `suppressed` verdict (suppression list + blacklist + deliverability), which is
        independent of the consent decision.
      responses:
        "200":
          description: The contact's consent picture.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactConsent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "`contact_not_found` — unknown contact, or another workspace's contact."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/contacts/{id}/consent/{channel}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
      - name: channel
        in: path
        required: true
        description: "`whatsapp` or `email`. Any other value returns 400 `invalid_channel`."
        schema:
          type: string
          enum: [whatsapp, email]
    put:
      operationId: setContactConsent
      tags: [Contacts]
      summary: Set a contact's consent on one channel
      description: |
        Records a subscribed/unsubscribed decision with its provenance — the same path
        every in-app consent change takes, so the consent-evidence ledger and activity
        timeline apply identically. `"unknown"` is a system state and cannot be set, and
        `deliverability` is provider-owned (bounce/complaint feedback) and is not
        writable. Subscribing a channel whose deliverability is `complained` is refused
        with 409 `consent_sticky_complained` — a spam complaint can never be lifted
        through the API.

        Returns the resulting channel object in the same shape GET uses, so the caller
        sees the applied state, stamped provenance, and deliverability (the email
        channel includes the `suppressed` verdict).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SetConsentRequest"
      responses:
        "200":
          description: The resulting single-channel consent object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentChannelState"
        "400":
          description: |
            Validation failure (standard shape), or `invalid_channel` (domain envelope) —
            the `{channel}` path segment must be `whatsapp` or `email`.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/DomainError"
                  - $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "`contact_not_found` — unknown contact, or another workspace's contact."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: |
            `consent_sticky_complained` — this channel has a spam complaint on record;
            consent cannot be re-subscribed via the API.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/contacts/{id}/documents:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: listContactDocuments
      tags: [Contact documents]
      summary: List a contact's financial documents
      description: |
        Read-only listing of the contact's financial documents — invoices, receipts, and
        credit documents — aggregated from every stored document pointer on the contact's
        payments, payment entries, and payment requests, deduplicated, merged, and sorted
        date-descending (nulls last). **Requires the `payments` plan feature** (the same
        gate as `/v1/payments*`), in addition to API access.

        Responds `{ documents, live }`. Each document carries `kind` (canonical type, or
        `null` with the provider's `rawType`), `isCredit`, `provider`, `documentId`,
        `number`, `url`, `date`, `amount`, `currency`, `origin`, and its `sources`. A
        document's `url` **may be null** (legacy number-only rows) — always check before
        opening.

        By default only **stored** document pointers are returned. Pass `?live=true` to
        additionally query the connected payment provider for a live document listing and
        merge it in (bounded to ~2.5 s; a timeout or provider failure degrades to the
        stored listing, reported in `live.error` — never an error response). With no
        connected/entitled provider the live lookup degrades to an empty listing.
      parameters:
        - name: live
          in: query
          required: false
          description: |
            `true` | `false` (default `false`). Any other value returns 400
            (`"Invalid live: must be true or false"`).
          schema:
            type: string
            enum: ["true", "false"]
      responses:
        "200":
          description: The aggregated document listing plus the live-lookup status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactDocumentsResult"
        "400":
          description: Non-UUID contact id, or a malformed `live` value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: |
            `"contacts with ID <id> not found"` — same lookup and wording as
            `GET /v1/contacts/{id}`, so an unknown or cross-workspace contact answers
            identically instead of returning an empty-but-plausible document list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/contacts/{id}/notes:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: listContactNotes
      tags: [Contact notes]
      summary: List a contact's notes
      description: |
        Returns a JSON **array** of all the contact's notes — not paginated, no query
        parameters. Pinned notes come first (most recently pinned on top), then the rest
        newest-first. `author_name` is included on list responses only.
      responses:
        "200":
          description: All notes for the contact.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Note"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createContactNote
      tags: [Contact notes]
      summary: Add a note
      description: |
        API note payloads are text only (rich text and mentions are in-app features; sending
        them returns 400). Notes created via the API have no author user and are attributed
        to source `api`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [body]
              properties:
                body:
                  type: string
                  maxLength: 5000
                  description: Trimmed — empty after trim → 400 "Note body cannot be empty".
                pinned:
                  type: boolean
                  description: "`true` pins the note immediately."
      responses:
        "201":
          description: The created note.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Note"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/notes/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    patch:
      operationId: updateNote
      tags: [Contact notes]
      summary: Edit / pin a note
      description: |
        Both fields optional; sending neither returns the current note unchanged. A body
        change bumps `updated_at`; a pin toggle alone does not. If the note is the contact's
        profile note, a body edit also updates the contact's `notes` field (and fires
        field-change automations).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                body:
                  type: string
                  maxLength: 5000
                  description: Empty → 400.
                pinned:
                  type: boolean
                  description: Pin/unpin.
      responses:
        "200":
          description: The note after the update.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Note"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    delete:
      operationId: deleteNote
      tags: [Contact notes]
      summary: Delete a note
      description: |
        Deleting the contact's profile note also clears the contact's `notes` field. A
        deletion breadcrumb is kept in the contact's activity timeline.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Tags ─────────────────────────────
  /v1/tags:
    get:
      operationId: listTags
      tags: [Tags]
      summary: List tags
      description: Standard list conventions; `search` matches the `name` field.
      parameters:
        - $ref: "#/components/parameters/FilterParam"
        - $ref: "#/components/parameters/SortParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
        - $ref: "#/components/parameters/SearchParam"
      responses:
        "200":
          description: Paginated tag list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Tag"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createTag
      tags: [Tags]
      summary: Create a tag
      description: |
        Tag names are unique per workspace (case-insensitive). A name that already exists
        returns **409 Conflict** — look up the existing record (list with `search=<name>`)
        and reuse it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TagWrite"
      responses:
        "201":
          description: The created tag with `usage_count` 0.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tag"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: '`"A tag with this name already exists"` — names are unique per workspace (case-insensitive).'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
              example:
                statusCode: 409
                message: A tag with this name already exists
                error: Conflict
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/tags/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getTag
      tags: [Tags]
      summary: Get one tag
      responses:
        "200":
          description: A tag object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tag"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateTag
      tags: [Tags]
      summary: Update a tag
      description: Same fields as create, all optional.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TagWrite"
      responses:
        "200":
          description: The updated tag.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tag"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: '`"A tag with this name already exists"` — renaming to another tag''s name.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Contact groups ─────────────────────────────
  /v1/contact-groups:
    get:
      operationId: listContactGroups
      tags: [Contact groups]
      summary: List groups
      description: Standard list conventions; `search` matches the `name` field.
      parameters:
        - $ref: "#/components/parameters/FilterParam"
        - $ref: "#/components/parameters/SortParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
        - $ref: "#/components/parameters/SearchParam"
      responses:
        "200":
          description: Paginated group list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/ContactGroup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createContactGroup
      tags: [Contact groups]
      summary: Create a group
      description: |
        Group names are unique per workspace (case-insensitive). A name that already exists
        returns **409 Conflict**.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactGroupWrite"
      responses:
        "201":
          description: The created group with `contact_count` 0.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactGroup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: '`"A contact group with this name already exists"` — names are unique per workspace (case-insensitive).'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
              example:
                statusCode: 409
                message: A contact group with this name already exists
                error: Conflict
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/contact-groups/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getContactGroup
      tags: [Contact groups]
      summary: Get one group
      responses:
        "200":
          description: A group object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactGroup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateContactGroup
      tags: [Contact groups]
      summary: Update a group
      description: Same fields as create, all optional.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactGroupWrite"
      responses:
        "200":
          description: The updated group.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactGroup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: '`"A contact group with this name already exists"` — renaming to another group''s name.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Campaigns ─────────────────────────────
  /v1/campaigns:
    get:
      operationId: listCampaigns
      tags: [Campaigns]
      summary: List campaigns
      description: |
        Standard list conventions. **List rows omit `variables` and `audience_filters`** —
        fetch a single campaign to read them.
      parameters:
        - $ref: "#/components/parameters/FilterParam"
        - $ref: "#/components/parameters/SortParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
        - $ref: "#/components/parameters/SearchParam"
      responses:
        "200":
          description: Paginated campaign list (rows omit `variables` and `audience_filters`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Campaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createCampaign
      tags: [Campaigns]
      summary: Create a campaign
      description: |
        If the campaign is saved with `status: "scheduled"` **and** a `scheduled_at`, it is
        automatically queued to execute at `scheduled_at` — you do not need to call
        `/execute`. Any change to the campaign re-syncs the schedule.

        At send time, the audience can only **narrow** the built-in WhatsApp
        send-eligibility baseline (subscribed, not blocked, deliverable) — never widen it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CampaignWrite"
      responses:
        "201":
          description: The full created campaign object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Campaign"
        "400":
          description: |
            Field validation, unknown fields, `"Invalid audience_filters definition"`
            (body includes an `errors` array; condition-tree values are type-checked like
            list filters), an unparseable date value
            (`Invalid date value for "scheduled_at": "<value>"`), or a workspace monthly
            campaign cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Audience not found"` — `audience_id` not in this workspace.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/campaigns/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getCampaign
      tags: [Campaigns]
      summary: Get one campaign
      responses:
        "200":
          description: The full campaign object (including `variables` and `audience_filters`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Campaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateCampaign
      tags: [Campaigns]
      summary: Update a campaign
      description: |
        Same fields as create, all optional; same audience validation and scheduling
        re-sync. The API can only set `status` to `draft` or `scheduled`. There is no
        restriction based on the campaign's current status — patching a running/completed
        campaign is possible and re-syncs scheduling, so take care not to accidentally
        re-schedule a finished campaign.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CampaignWrite"
      responses:
        "200":
          description: The updated campaign.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Campaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/campaigns/{id}/execute:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: executeCampaign
      tags: [Campaigns]
      summary: Queue a scheduled campaign for immediate execution
      description: |
        The campaign must have `status: "scheduled"` — a draft must first be PATCHed to
        `scheduled`. No request body.

        Success answers **200**; failures use real error statuses with the structured
        `{"error": {"code", "message"}}` envelope (404 `campaign_not_found`,
        409 `campaign_not_scheduled`).

        A campaign created via `POST /v1/campaigns` without an explicit `status` defaults
        to `draft`, so a naive create → execute sequence returns the 409 — set
        `status: "scheduled"` on create, or PATCH it, before executing.

        Execution is queued with a per-campaign job id, so repeated execute calls while a
        run is queued do not enqueue duplicates. Delivery progress is reflected in the
        campaign's counters, which you can poll via `GET /v1/campaigns/{id}`.
      responses:
        "200":
          description: The campaign was queued for execution.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CampaignExecuteResult"
              example:
                success: true
                message: Campaign queued for execution
                jobId: execute-1f2e3d4c-...
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`campaign_not_found` — the campaign id is not in this workspace.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: campaign_not_found
                  message: Campaign not found
        "409":
          description: |
            `campaign_not_scheduled` — the campaign is not in `scheduled` status (draft
            campaigns 409 on execute).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: campaign_not_scheduled
                  message: "Campaign status is 'draft' — only 'scheduled' campaigns can be executed"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Templates ─────────────────────────────
  /v1/templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      description: |
        Standard list conventions. Only templates with an approved `status` can be delivered
        by WhatsApp.
      parameters:
        - $ref: "#/components/parameters/FilterParam"
        - $ref: "#/components/parameters/SortParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
        - $ref: "#/components/parameters/SearchParam"
      responses:
        "200":
          description: Paginated template list (full rows).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Template"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/templates/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Get one template
      responses:
        "200":
          description: A full template row.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/templates/{id}/send:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: sendTemplate
      tags: [Templates]
      summary: Send a template message
      description: |
        Sends the template to a phone number. The template must belong to your workspace
        (404 otherwise). The recipient contact is matched by normalized phone — **created
        automatically if it doesn't exist** — and the sent message is recorded in the Inbox
        conversation.

        The nested shapes of `body_variables` / `header_config` / `button_configs` are
        **strictly validated on the request**: a malformed entry (e.g.
        `body_variables: [null]`, or a button without `parameters`) returns 400 in the
        standard validation shape, where `message` is an array of per-field strings (e.g.
        `["body_variables.0.text must be a string"]`). Unknown properties inside these
        nested objects are rejected too (`property <name> should not exist`). Content that
        only the WhatsApp provider rejects still surfaces as a 502.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateSendRequest"
      responses:
        "201":
          description: The message was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateSendResult"
        "400":
          description: |
            Malformed nested `body_variables`/`header_config`/`button_configs` entries or
            unknown nested properties (`message` is an array of per-field strings, e.g.
            `["body_variables.0.text must be a string"]`), missing required template
            variables/config
            (`"Cannot send WhatsApp template — missing required field(s): …"`), or a bad
            `header_config.media_type`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `"This contact is blocked"`, or
            `"This phone number is on the blacklist and cannot be messaged"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "404":
          description: |
            Template not in this workspace, or `"No connected WhatsApp instance found"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "502":
          description: |
            WhatsApp/Meta refused the send. **The failed message row is persisted** —
            `message_id`/`conversation_id` are included so you can retry or inspect it in
            the Inbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: The provider error.
                  message_id:
                    type: string
                    format: uuid
                  conversation_id:
                    type: string
                    format: uuid

  # ───────────────────────────── Pipelines & Deals ─────────────────────────────
  /v1/pipelines:
    get:
      operationId: listPipelines
      tags: [Pipelines]
      summary: List pipelines with their stages
      description: |
        Returns a JSON **array** (no pagination envelope) of the workspace's pipelines,
        ordered by position, each with its ordered `stages`. Exactly one pipeline per
        workspace has `is_default: true`. Use this to map stage ids before creating or
        moving deals.
      responses:
        "200":
          description: All pipelines with stages.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Pipeline"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/deals:
    get:
      operationId: listDeals
      tags: [Deals]
      summary: List deals
      description: |
        Uses dedicated query parameters (not the generic `filter`) and its own pagination
        defaults: `limit` default 25 (cap 100), `offset` min 0. Absent or empty
        `limit`/`offset` values default; malformed values return 400
        (`"Invalid limit: must be a non-negative integer"`). The UUID query parameters are
        validated — a malformed value returns 400 (`"Invalid pipeline_id: must be a
        UUID"`), while an empty value (`?pipeline_id=`) is treated as absent. Unrecognized
        enum filter values are still silently ignored rather than returning 400. Results
        are ordered newest-first. Rows include the joined
        `contact_name`/`contact_phone`/`contact_email`.
      parameters:
        - name: pipeline_id
          in: query
          description: |
            Exact match — take the id from `GET /v1/pipelines`. Malformed → 400
            `"Invalid pipeline_id: must be a UUID"`; empty is treated as absent.
          schema:
            type: string
            format: uuid
        - name: stage_id
          in: query
          description: 'Exact match. Malformed → 400 `"Invalid stage_id: must be a UUID"`.'
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: Any other value is silently ignored (unfiltered result).
          schema:
            type: string
            enum: [open, won, lost]
        - name: contact_id
          in: query
          description: 'Exact match. Malformed → 400 `"Invalid contact_id: must be a UUID"`.'
          schema:
            type: string
            format: uuid
        - name: owner_user_id
          in: query
          description: 'Exact match. Malformed → 400 `"Invalid owner_user_id: must be a UUID"`.'
          schema:
            type: string
            format: uuid
        - name: external_reference
          in: query
          description: Exact match — look up a deal by your idempotency reference.
          schema:
            type: string
        - name: search
          in: query
          description: Case-insensitive match over deal title + contact name/phone/email.
          schema:
            type: string
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated deal list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Deal"
        "400":
          description: |
            A malformed UUID query parameter (`"Invalid pipeline_id: must be a UUID"`, …)
            or a malformed `limit`/`offset`
            (`"Invalid limit: must be a non-negative integer"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: upsertDeal
      tags: [Deals]
      summary: Create a deal (idempotent upsert via `external_reference`)
      description: |
        Creates a deal — or, when `external_reference` matches an existing deal in the
        workspace, **updates it**:

        - Fields updated (only those present in the body): `product_id` (when a product
          reference resolves), `title` (still ignored while a product is attached),
          `amount`, `currency`, `owner_user_id`, `expected_close_at`, `note`.
        - `contact_id` is always re-applied from the freshly resolved contact — a repeat
          POST with different phone/email re-points the deal to that contact. Every repeat
          POST must still carry `contact_id` or `phone`/`email`.
        - `stage_id`, if present and different from the current stage, **moves** the deal
          (same ledger/automation effects as `POST /v1/deals/{id}/stage`).
        - `status` is never touched on a match; `pipeline_id` is ignored on a match.

        Contact resolution is shared with the contacts upsert: `contact_id` wins; otherwise
        `phone`/`email` are upserted (phone match wins, email fallback). While a product is
        attached, the deal's `title` derives from the product name and any client-sent
        title is ignored; a missing `amount` defaults to the product's price.

        The response is **201 in both cases** and carries a top-level boolean `duplicate`
        field — `false` when this request created the deal, `true` when the
        `external_reference` matched an existing deal.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DealCreate"
      responses:
        "201":
          description: |
            The deal (created — `duplicate: false` — or updated — `duplicate: true`) +
            joined contact identity fields.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DealUpsertResult"
        "400":
          description: |
            Coded errors (as `error_code`): `MISSING_TITLE`, `STAGE_PIPELINE_MISMATCH`,
            `NO_PIPELINE`, `PIPELINE_HAS_NO_STAGES`, `INVALID_DEAL_OWNER`,
            `INVALID_PRODUCT`, `PRODUCT_INACTIVE` — or a missing contact reference
            (`"Provide contact_id, or a phone/email…"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Plan-wide API access, `error_code: FEATURE_NOT_INCLUDED_IN_PLAN` — the plan
            lacks the Deals feature (body without a `statusCode` field) — or
            `error_code: PLAN_LIMIT_EXCEEDED` — deal cap reached (only when a cap is set
            on the workspace).
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/FeatureGateError"
        "404":
          description: '`"Contact not found"` / `"Pipeline not found"` / `"Stage not found"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          $ref: "#/components/responses/ContactMergeRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/deals/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getDeal
      tags: [Deals]
      summary: Get one deal
      responses:
        "200":
          description: The deal + `contact_name`/`contact_phone`/`contact_email`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Deal"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateDeal
      tags: [Deals]
      summary: Update deal fields
      description: |
        Field updates do **not** write stage-history entries and do **not** fire deal
        automations — only the `/stage` and `/status` routes do.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DealPatch"
      responses:
        "200":
          description: The updated deal + joined contact fields.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Deal"
        "400":
          description: "`INVALID_PRODUCT` / `PRODUCT_INACTIVE` / `INVALID_DEAL_OWNER` / validation."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Deal not found"` / `"Contact not found"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/deals/{id}/stage:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: moveDealStage
      tags: [Deals]
      summary: Move a deal to a stage
      description: |
        Move a deal to any stage in the workspace — cross-pipeline moves are supported; the
        deal's `pipeline_id` follows the target stage. A move to the deal's **current**
        stage is a pure reorder (no history entry, no automations). A genuine stage change
        stamps `stage_entered_at`, records a stage-transition history entry and activity
        row, and fires deal-stage-changed automations.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [stage_id]
              properties:
                stage_id:
                  type: string
                  format: uuid
                  description: Target stage.
                index:
                  type: integer
                  minimum: 0
                  maximum: 100000
                  description: Position within the stage column, 0 = top. Omitted → top.
      responses:
        "201":
          description: The updated deal row (no joined contact fields on this route).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Deal"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Deal not found"` / `"Stage not found"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/deals/{id}/status:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: setDealStatus
      tags: [Deals]
      summary: "Set status: open / won / lost"
      description: |
        Closing (`won`/`lost`) stamps `closed_at` and keeps the deal on its last stage.
        Reopening (`open`) clears `closed_at` and `lost_reason`. `won`/`lost` fire the
        corresponding deal automations (reopen fires none). If the deal already has the
        requested status, the current row is returned unchanged (no history entry, no
        automations).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [status]
              properties:
                status:
                  type: string
                  enum: [open, won, lost]
                  description: "`open` reopens a closed deal."
                lost_reason:
                  type: string
                  maxLength: 1000
                  description: Stored only when status is `lost`; cleared otherwise.
      responses:
        "201":
          description: The updated deal row (no joined contact fields).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Deal"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Products ─────────────────────────────
  /v1/products:
    get:
      operationId: listProducts
      tags: [Products]
      summary: List products
      description: |
        Standard list envelope, newest first. Filters combine (AND). `q` is a literal
        substring match on `name` or `description` (case-insensitive; `%`/`_` are not
        wildcards); `sku` and `external_id` are exact-match lookups.
      parameters:
        - name: q
          in: query
          description: Substring match on `name` or `description`.
          schema:
            type: string
        - name: sku
          in: query
          description: Exact-match lookup by SKU.
          schema:
            type: string
        - name: external_id
          in: query
          description: Exact-match lookup by external id.
          schema:
            type: string
        - name: is_active
          in: query
          description: "`true` | `false`; any other value returns 400."
          schema:
            type: boolean
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
      responses:
        "200":
          description: Paginated product list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Product"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: upsertProduct
      tags: [Products]
      summary: Create a product (idempotent upsert via external_id)
      description: |
        When `external_id` matches an existing product, that product's fields are
        updated instead of a new one being created and the response carries
        `duplicate: true` (201 for both outcomes). `sku` and `external_id` are each
        per-workspace-unique — a clash with a **different** product answers 409
        `product_conflict`. `vat_mode` + `vat_rate` travel as one both-or-neither pair
        (400 when only one leg is sent).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/ProductWrite"
                - type: object
                  required: [name]
      responses:
        "201":
          description: The created (or `external_id`-matched and updated) product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductUpsertResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: "`product_conflict` — a different product already holds this `sku` or `external_id`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/products/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getProduct
      tags: [Products]
      summary: Get a product
      responses:
        "200":
          description: The product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "`product_not_found` — unknown product, or another workspace's product."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateProduct
      tags: [Products]
      summary: Update a product
      description: |
        Partial update — only the fields present in the body change (a field you don't
        send is never nulled). There is no DELETE: deactivate with
        `{"is_active": false}` so records that reference the product stay intact.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductWrite"
      responses:
        "200":
          description: The updated product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "`product_not_found` — unknown product, or another workspace's product."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: "`product_conflict` — a different product already holds this `sku` or `external_id`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Payments ─────────────────────────────
  /v1/payments:
    get:
      operationId: listPayments
      tags: [Payments]
      summary: List payments
      description: |
        Uses dedicated query parameters and its own pagination defaults (`limit` default 25,
        cap 100). Absent or empty `limit`/`offset` values default; malformed values return
        400 (`"Invalid limit: must be a non-negative integer"`). Unrecognized enum values
        are still silently ignored. Ordered by `purchase_date` descending. Rows include
        joined `contact_name`/`contact_phone`/`contact_email`. **List rows do not include
        entries.**
      parameters:
        - name: type
          in: query
          description: Other values are silently ignored.
          schema:
            type: string
            enum: [one_time, recurring, installments]
        - name: status
          in: query
          description: "Arrangement status: other values silently ignored."
          schema:
            type: string
            enum: [active, completed, cancelled]
        - name: search
          in: query
          description: Case-insensitive match over payment title + contact name/phone/email.
          schema:
            type: string
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated payment list (headers only, no entries).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Payment"
        "400":
          description: |
            A malformed `limit`/`offset`
            (`"Invalid limit: must be a non-negative integer"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: upsertPayment
      tags: [Payments]
      summary: Create a payment (idempotent upsert via `external_reference`)
      description: |
        Creates a payment — or, when `external_reference` matches an existing payment,
        **updates it**. Only mutable fields are applied on a match:

        - Always (when present): `product_id`, `title` (subject to the product title lock),
          `note`, `method`, `metadata` (the provided object **replaces** the stored one —
          omit it to keep it).
        - Only when the existing payment is `one_time`: `amount`, `status`.
        - Only when it is `recurring`: `auto_generate`, `recurring_end_at`,
          `recurring_max_occurrences`, and the `vat_mode`+`vat_rate` pair (a full pair
          **re-prices** the plan — future cycles are charged and documented with the new
          posture).
        - Never restructured on a match: `type`, `interval`, `installment_count`,
          `purchase_date` — and, unlike deals, **the contact is NOT re-pointed** on a match.

        Entry creation per type: **one_time** — one entry with the given `status`
        (the header's `arrangement_status` mirrors it); **recurring** — the first cycle is
        recorded now when `record_first_payment` is true (default), further cycles record
        automatically when `auto_generate` is on; **installments** — `installment_count`
        monthly entries starting at `purchase_date`, recognized as revenue immediately.

        Contact resolution is identical to deals (409 `CONTACT_MERGE_REQUIRED` on identity
        conflict). Unlike deals, `amount` is always required — the product price does not
        substitute for it. The response is **201 in both cases** and carries a top-level
        boolean `duplicate` field — `false` when this request created the payment, `true`
        when the `external_reference` matched an existing payment.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentCreate"
      responses:
        "201":
          description: |
            The payment (created — `duplicate: false` — or updated — `duplicate: true`)
            with its entries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentUpsertResult"
        "400":
          description: |
            Bad type/amount, installment/schedule validation, `INVALID_PRODUCT` /
            `PRODUCT_INACTIVE`, a missing contact reference, a VAT-pair violation
            (`"vatMode/vatRate apply to recurring plans only"` /
            `"vat_mode and vat_rate must be provided together"`), or an oversized
            `metadata` object (`"metadata exceeds 2048 bytes serialized"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Contact not found"` — `contact_id` not in this workspace.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          $ref: "#/components/responses/ContactMergeRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/payments/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getPayment
      tags: [Payments]
      summary: Get a payment with its entries
      responses:
        "200":
          description: "`{ ...header, entries: [...] }` with entries ordered by `sequence`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentWithEntries"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updatePayment
      tags: [Payments]
      summary: Update a payment
      description: |
        A one-time `amount`/`status` change flows into the payment's single entry and
        re-maps the header's `arrangement_status`. Shortening a recurring plan's end
        conditions may auto-complete it; extending them never silently reactivates a
        completed plan. A one-time status change fires the matching automation after the
        update.

        A full `vat_mode`+`vat_rate` pair replaces a **recurring** plan's stored VAT
        posture (future cycles are charged and documented with the new pair) — lone legs
        and `null`s are rejected, and on non-recurring payments the pair returns 400.
        `metadata` replaces the stored object (`null` clears it; omit to keep it).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentPatch"
      responses:
        "200":
          description: The payment with entries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentWithEntries"
        "400":
          description: |
            `"This charge already has refunds; reverse those instead of marking it
            refunded/failed"` — use `/refund` — or recurring end-condition validation
            errors, VAT-pair violations (`"vatMode/vatRate apply to recurring plans
            only"` / `"vatMode and vatRate must be provided together"`), or an oversized
            `metadata` object (`"metadata exceeds 2048 bytes serialized"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/payments/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: cancelPayment
      tags: [Payments]
      summary: Cancel a recurring plan
      description: |
        Cancels a **recurring** plan. No request body. Already-recorded entries are
        untouched.
      responses:
        "201":
          description: |
            The payment with `arrangement_status: "cancelled"`, `recurring_cancelled_at`
            set, auto-generation off, and no next due date.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentWithEntries"
        "400":
          description: '`"Only recurring payments can be cancelled"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/payments/{id}/entries/{entryId}/mark:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
      - name: entryId
        in: path
        required: true
        description: The entry to mark; must belong to the payment in the URL.
        schema:
          type: string
          format: uuid
    post:
      operationId: markPaymentEntry
      tags: [Payments]
      summary: Set one entry's status
      description: |
        Marking `completed` stamps `paid_at` and recognizes the revenue. A one-time header
        mirrors its entry's status; recurring/installment headers are **not** changed by
        marking one cycle. Status-change automations fire as on PATCH (payment-recorded only
        for a newly recognized completion).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [status]
              properties:
                status:
                  type: string
                  enum: [pending, completed, failed, refunded]
      responses:
        "201":
          description: The full parent payment with entries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentWithEntries"
        "400":
          description: |
            `"Refund entries cannot be marked directly"` or `"This charge already has
            refunds; reverse those instead of marking it refunded/failed"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: |
            `"Payment entry not found"` (unknown, foreign, or not an entry of this payment)
            or `"Payment not found"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/payments/{id}/refund:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: refundPayment
      tags: [Payments]
      summary: Record a refund against a charge
      description: |
        Records a refund entry against a completed charge. Multiple partial refunds against
        one charge are supported, up to its recognized value; refunds are race-safe —
        concurrent refund requests cannot over-refund a charge. A fully refunded
        **one-time** payment's header becomes `arrangement_status: "cancelled"`; partial
        refunds and multi-entry deals keep their status. Fires the payment-refunded
        automation with the refunded amount.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                entry_id:
                  type: string
                  format: uuid
                  description: |
                    The charge entry to refund. May be omitted only when the payment has
                    exactly one charge.
                amount:
                  type: number
                  exclusiveMinimum: 0
                  maximum: 9999999999
                  description: Partial refund amount. Omitted → the full remaining refundable balance.
                note:
                  type: string
                  maxLength: 1000
                  description: Stored on the refund entry.
      responses:
        "201":
          description: |
            The payment with a new entry: `kind: "refund"`, negative
            `amount`/`recognized_amount`, `status: "refunded"`, `refunds_entry_id` pointing
            at the charge.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentWithEntries"
        "400":
          description: |
            Refund validation: `"entryId is required to refund a payment with multiple
            charges"`, `"Only a completed (recognized) charge can be refunded"`, `"This
            charge is not recognized as revenue yet and cannot be refunded"`, `"Refund
            amount must be a positive number"`, `"This charge is no longer refundable"` /
            `"This charge is already fully refunded"`, or `"Refund amount exceeds the
            refundable balance (<max>)"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Payment not found"` / `"Payment entry not found"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Payment requests ─────────────────────────────
  /v1/payment-requests:
    get:
      operationId: listPaymentRequests
      tags: [Payment requests]
      summary: List payment requests
      description: |
        Uses dedicated query parameters and the deals/payments pagination family
        (`limit` default 25, cap 100; absent or empty values default; malformed values
        return 400 `"Invalid limit: must be a non-negative integer"`). Unlike
        deals/payments, an unknown `status` value returns 400 rather than being silently
        ignored. Ordered by `created_at` descending. Rows include joined
        `contact_name`/`contact_phone`/`contact_email` and a computed `refunded_total`.
      parameters:
        - name: status
          in: query
          description: Unknown values return 400.
          schema:
            type: string
            enum: [pending, paid, expired, cancelled]
        - name: contact_id
          in: query
          description: Malformed (non-UUID) values return 400; empty values are treated as absent.
          schema:
            type: string
            format: uuid
        - name: deal_id
          in: query
          description: Malformed (non-UUID) values return 400; empty values are treated as absent.
          schema:
            type: string
            format: uuid
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated payment-request list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/PaymentRequest"
        "400":
          description: |
            A malformed `limit`/`offset`
            (`"Invalid limit: must be a non-negative integer"`), a malformed UUID filter
            (`"Invalid contact_id: must be a UUID"`), or an unknown `status`
            (`"Invalid status: must be one of pending, paid, expired, cancelled"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createPaymentRequest
      tags: [Payment requests]
      summary: Create a payment request (mint a pay-link)
      description: |
        Mints a hosted-checkout pay-link through the workspace's connected payment
        provider (Cardcom / Sumit) and returns the row with its shareable `pay_url`.
        Requires a connected provider — otherwise 400 with
        `error_code: "NO_PAYMENT_PROVIDER"`.

        **NOT idempotent — there is no idempotency key on this resource.** A repeat POST
        mints a second, **independently payable** link; cancel extras via
        `POST /v1/payment-requests/{id}/cancel`. Do not blindly retry this call after a
        network failure without first checking `GET /v1/payment-requests` for the link
        you may have already minted.

        The payer contact resolves like payments/deals: `contact_id` wins; otherwise
        `phone`/`email` are upserted like `POST /v1/contacts` (same
        409 `CONTACT_MERGE_REQUIRED` behavior); a `deal_id` alone also works — the deal's
        contact pays. None of the four → 400.

        Expiry is clamped server-side to at most 72 hours from now (1 hour for test-mode
        requests); reminder emails, payment verification, tax-document auto-issue, and
        the payer receipt behave exactly as for in-app-minted links. The response also
        carries `checkout_url`/`checkout_error`: a provider failure at mint time leaves
        the row `pending` **without** a checkout session (`checkout_error` set) — the
        public pay page lazily re-creates the session when opened, so the link still
        works once the provider recovers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentRequestCreate"
      responses:
        "201":
          description: The minted payment request, including `pay_url`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentRequestCreateResult"
        "400":
          description: |
            `error_code: "NO_PAYMENT_PROVIDER"` (no connected provider), body
            validation (amount bounds, unsupported `currency`, bad `expires_at`),
            a VAT-pair violation (`"vat_mode and vat_rate must be provided together"`),
            `test_mode: true` when the connected provider has no test mode, or a missing
            payer reference (`"Provide contact_id, a phone/email to resolve the payer
            contact, or a deal_id"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: Unknown `contact_id` or `deal_id` in this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          $ref: "#/components/responses/ContactMergeRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/payment-requests/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getPaymentRequest
      tags: [Payment requests]
      summary: Get a payment request
      responses:
        "200":
          description: |
            The payment request, with its computed `pay_url` and — once paid — the
            issued tax `document` pointer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Payment request not found"` — unknown in this workspace.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/payment-requests/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: cancelPaymentRequest
      tags: [Payment requests]
      summary: Cancel a pending payment request
      description: |
        Withdraws the pay-link — the hosted page stops accepting payment. No request
        body. **Pending requests only** (the cancel is a compare-and-set on `status`):
        already paid/expired/cancelled rows answer 409, and direct saved-card charge rows
        answer 409 `TOKEN_REQUEST_NOT_CANCELLABLE`. A payer already on the hosted page
        can still complete — such late completions are verified and recorded (the row
        resurrects to `paid`) rather than dropping the money.
      responses:
        "201":
          description: |
            The cancelled row (`status: "cancelled"`, `cancelled_at` stamped). Unlike
            the other reads, the cancel response is the bare row — it carries no
            computed `pay_url`/`document` fields.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Payment request not found"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: |
            `"Only pending payment requests can be cancelled"` (already
            paid/expired/cancelled), or `error_code: "TOKEN_REQUEST_NOT_CANCELLABLE"` —
            direct saved-card charge rows are resolved by the charge orchestration and
            can never be operator-cancelled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Orders ─────────────────────────────
  /v1/orders:
    get:
      operationId: listOrders
      tags: [Orders]
      summary: List orders
      description: |
        Uses dedicated query parameters (no generic `filter`) and the deals/payments
        pagination defaults (`limit` default 25, cap 100) — with one deliberate
        difference: paging on this route **never returns 400**. Malformed or zero
        `limit`/`offset` values silently default (25 / 0) and out-of-range values are
        clamped. Unrecognized `status`/`source` values are silently ignored (unfiltered
        result). Ordered by **`placed_at` descending** (not `created_at`). Rows include
        the joined `contact_name`/`contact_phone`/`contact_email` and **omit
        `items`/`refunds`** — fetch a single order to read them. There is no `search`
        parameter on this route.
      parameters:
        - name: status
          in: query
          description: Financial status. Any other value is silently ignored (unfiltered result).
          schema:
            type: string
            enum: [pending, paid, partially_paid, refunded, partially_refunded, voided]
        - name: contact_id
          in: query
          description: |
            Exact match. Malformed → 400 `"contact_id must be a UUID"` (note the wording
            differs from deals/payments); empty (`?contact_id=`) is treated as absent.
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          description: |
            Exact match — `manual`, `api`, or `automation`; orders synced from a
            connected store carry the store platform name.
          schema:
            type: string
        - name: store_connection_id
          in: query
          description: |
            Exact match on the store-sync provenance field — orders synced from a
            connected store. Malformed → 400 `"store_connection_id must be a UUID"`.
          schema:
            type: string
            format: uuid
        - name: external_reference
          in: query
          description: Exact match — look up an order by your idempotency reference.
          schema:
            type: string
        - name: placed_from
          in: query
          description: 'Orders placed at/after this instant. Unparseable → 400 `"placed_from is not a valid date"`.'
          schema:
            type: string
            format: date-time
        - name: placed_to
          in: query
          description: 'Orders placed at/before this instant. Unparseable → 400 `"placed_to is not a valid date"`.'
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/LenientLimitParam"
        - $ref: "#/components/parameters/LenientOffsetParam"
      responses:
        "200":
          description: Paginated order list (rows omit `items`/`refunds`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Order"
        "400":
          description: |
            A malformed UUID query parameter (`"contact_id must be a UUID"`,
            `"store_connection_id must be a UUID"`) or an unparseable date bound
            (`"placed_from is not a valid date"`, `"placed_to is not a valid date"`).
            Never a paging error on this route.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createOrder
      tags: [Orders]
      summary: Create an order (idempotent upsert via `external_reference`)
      description: |
        Creates an order — or, when `external_reference` matches an existing order in the
        workspace, **updates its mutable fields** (race-safe: a concurrent create losing
        the uniqueness race retries as an update against the winner). On a match:

        - Always updated (when present in the body): `note`, `coupon_codes`, `placed_at`,
          `deal_id`.
        - Money fields (`items`, `currency`, `discount_total`, `shipping_total`,
          `tax_total`) apply **only while the order is still `pending`** — once paid,
          money is locked and repeat POSTs silently skip these fields (corrections flow
          through refunds/cancel).
        - `financial_status` is **never** changed on a match — an exact replay of a
          created-as-paid POST is a clean no-op, and a repeat POST can never resurrect a
          cancelled order. Status moves ride the mark-paid/cancel/refund endpoints.
        - The contact is **never re-pointed** on a match (unlike deals). Contact
          resolution still runs first, so a repeat POST must still carry `contact_id` or
          `phone`/`email` (and phone/email upsert side effects still apply to the contact
          itself).

        Contact resolution is shared with the contacts upsert: `contact_id` wins;
        otherwise `phone`/`email` are upserted (phone match wins, email fallback). Line
        totals are server-computed: `line_total` = round2(`quantity` × `unit_price` ×
        (1 − `discount_percent`/100)); `subtotal` = the sum of rounded line totals;
        `total` = max(0, `subtotal` − `discount_total` + `shipping_total` + `tax_total`).
        Rounding is 2-decimal half-up.

        The response is **201 in both cases** with the full order (items + refunds +
        joined contact identity). **Unlike the other idempotent creates there is no
        top-level `duplicate` field** — both outcomes return the same body. To
        distinguish, compare `created_at` or pre-check with
        `GET /v1/orders?external_reference=...`.

        Creation fires order-created automations (plus order-paid when created as
        `paid`), writes the contact's activity timeline, and emits registered
        `order.created` (and `order.paid`) webhooks; a paid create records a completed
        payment for the full order total in the order's currency on the contact's
        payment history. Writes are attributed to source `api`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderCreate"
      responses:
        "201":
          description: |
            The full order (created or updated — same body either way, no `duplicate`
            marker) with `items`, `refunds`, and joined contact identity.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderDetail"
        "400":
          description: |
            Coded errors (as `error_code`): `INVALID_PRODUCT`, `PRODUCT_INACTIVE`,
            `ORDER_ITEM_PRICE_REQUIRED` (a dynamic-priced product without an explicit
            `unit_price`) — or a missing contact reference (`"Provide contact_id, or a
            phone/email…"`), a line with neither product nor title (`"Order items
            require a title or product_id"`), `"placed_at is not a valid date"`,
            money-field checks (`"discount_total must be a non-negative number"`, …), or
            a validation array.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Plan-wide API access, `error_code: FEATURE_NOT_INCLUDED_IN_PLAN` — the plan
            lacks the Orders feature (body without a `statusCode` field) — or
            `error_code: PLAN_LIMIT_EXCEEDED` — order cap reached (only when a cap is
            set on the workspace; orders are uncapped by default on all tiers).
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/FeatureGateError"
        "404":
          description: |
            `"Contact not found"` (`contact_id` not in this workspace), or
            `error_code: "ORDER_DEAL_NOT_FOUND"` — `deal_id` unknown to the workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: |
            Coded conflicts (as `error_code`): `CONTACT_MERGE_REQUIRED` (phone and email
            resolve to two different existing contacts — a merge request is opened, its
            `merge_request_id` is included, and the order is **not** created),
            `ORDER_DEAL_CONTACT_MISMATCH` (`deal_id` belongs to another contact), or
            `ORDER_REFERENCE_EXISTS` (`"An order with this external_reference already
            exists."` — normally auto-resolved into an update by the retry path, so
            callers should not normally observe it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/orders/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getOrder
      tags: [Orders]
      summary: Get one order with items and refunds
      responses:
        "200":
          description: |
            The full order: header + `items` (ordered by `position`) + `refunds`
            (ordered by `refunded_at` ascending) + joined contact identity.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderDetail"
              example:
                id: 5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c
                workspace_id: 0f1e2d3c-4b5a-4978-8796-a5b4c3d2e1f0
                contact_id: 9e8d7c6b-5a49-4837-9605-f4e3d2c1b0a9
                contact_name: Dana Levi
                contact_phone: "+972501234567"
                contact_email: dana@example.com
                order_number: 1042
                number: null
                platform: api
                source: api
                store_connection_id: null
                store_domain: null
                external_order_id: null
                external_reference: shop-88123
                deal_id: null
                financial_status: pending
                fulfillment_status: unfulfilled
                currency: ILS
                total: 360
                subtotal: 340
                discount_total: 0
                shipping_total: 20
                tax_total: 0
                refunded_total: 0
                item_count: 2
                first_item_name: Widget
                coupon_codes: []
                placed_at: "2026-07-14T09:58:00.000Z"
                paid_at: null
                cancelled_at: null
                refunded_at: null
                external_updated_at: null
                payment_reference: null
                payment_synced_at: null
                note: null
                metadata: null
                created_by: null
                created_at: "2026-07-14T09:58:04.000Z"
                updated_at: "2026-07-14T09:58:04.000Z"
                items:
                  - id: 1a2b3c4d-5e6f-4a0b-9c8d-7e6f5a4b3c2d
                    workspace_id: 0f1e2d3c-4b5a-4978-8796-a5b4c3d2e1f0
                    order_id: 5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c
                    position: 0
                    product_id: null
                    external_product_id: null
                    title: Widget
                    sku: WID-1
                    quantity: 2
                    unit_price: 170
                    discount_percent: null
                    line_total: 340
                    created_at: "2026-07-14T09:58:04.000Z"
                refunds: []
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: |
            `"Order not found"` — unknown id or another workspace's order
            (deliberately indistinguishable).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/orders/{id}/refunds:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: refundOrder
      tags: [Orders]
      summary: Record a refund (idempotent via `external_refund_id`)
      description: |
        Appends to the order's append-only refund ledger, rolls `financial_status` to
        `partially_refunded` or `refunded` (a full-rollup refund → `refunded`; these two
        states are reachable **only** through refunds, never through a status endpoint),
        stamps `refunded_at` on the header, mirrors the refund into the recorded
        payment, and fires order-refunded automations + the `order.refunded` webhook.

        Refunds require the order to have **ever been paid** (`paid_at` set — includes
        `partially_paid`); refunding a cancelled-but-paid order is allowed (cancellation
        doesn't touch the money axis). The amount must not exceed the remaining total
        (`total` − `refunded_total`).

        **Idempotency:** a repeat POST with the same `external_refund_id` applies
        nothing and returns `duplicate: true` with the current order state. **Without
        `external_refund_id`, refunds are NOT idempotent** — every POST appends a new
        refund; supply one whenever your system can retry.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [amount]
              properties:
                amount:
                  type: number
                  exclusiveMinimum: 0
                  maximum: 9999999999
                  description: |
                    In the order's currency; must not exceed the remaining total
                    (`total` − `refunded_total`).
                external_refund_id:
                  type: string
                  maxLength: 255
                  description: |
                    **Idempotency key** — a repeat POST with the same value on this
                    order applies nothing and returns `duplicate: true`. Strongly
                    recommended whenever your system can retry.
                reason:
                  type: string
                  maxLength: 1000
                refunded_at:
                  type: string
                  format: date-time
                  description: ISO 8601; defaults to now.
      responses:
        "201":
          description: The `duplicate` marker plus the full current order (items + refunds).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderRefundResult"
              example:
                duplicate: false
                order:
                  id: 5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c
                  workspace_id: 0f1e2d3c-4b5a-4978-8796-a5b4c3d2e1f0
                  contact_id: 9e8d7c6b-5a49-4837-9605-f4e3d2c1b0a9
                  contact_name: Dana Levi
                  contact_phone: "+972501234567"
                  contact_email: dana@example.com
                  order_number: 1042
                  number: null
                  platform: api
                  source: api
                  store_connection_id: null
                  store_domain: null
                  external_order_id: null
                  external_reference: shop-88123
                  deal_id: null
                  financial_status: partially_refunded
                  fulfillment_status: unfulfilled
                  currency: ILS
                  total: 360
                  subtotal: 340
                  discount_total: 0
                  shipping_total: 20
                  tax_total: 0
                  refunded_total: 50
                  item_count: 2
                  first_item_name: Widget
                  coupon_codes: []
                  placed_at: "2026-07-14T09:58:00.000Z"
                  paid_at: "2026-07-14T10:12:00.000Z"
                  cancelled_at: null
                  refunded_at: "2026-07-15T08:30:00.000Z"
                  external_updated_at: null
                  payment_reference: pay-2026-1042
                  payment_synced_at: "2026-07-14T10:12:01.000Z"
                  note: null
                  metadata: null
                  created_by: null
                  created_at: "2026-07-14T09:58:04.000Z"
                  updated_at: "2026-07-15T08:30:00.000Z"
                  items:
                    - id: 1a2b3c4d-5e6f-4a0b-9c8d-7e6f5a4b3c2d
                      workspace_id: 0f1e2d3c-4b5a-4978-8796-a5b4c3d2e1f0
                      order_id: 5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c
                      position: 0
                      product_id: null
                      external_product_id: null
                      title: Widget
                      sku: WID-1
                      quantity: 2
                      unit_price: 170
                      discount_percent: null
                      line_total: 340
                      created_at: "2026-07-14T09:58:04.000Z"
                  refunds:
                    - id: 2b3c4d5e-6f7a-4b1c-8d9e-0f1a2b3c4d5e
                      workspace_id: 0f1e2d3c-4b5a-4978-8796-a5b4c3d2e1f0
                      order_id: 5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c
                      external_refund_id: r-1
                      amount: 50
                      currency: ILS
                      reason: damaged
                      refunded_at: "2026-07-15T08:30:00.000Z"
                      created_at: "2026-07-15T08:30:00.000Z"
        "400":
          description: |
            `error_code: "ORDER_NEVER_PAID"` (`"Cannot refund an order that was never
            paid."`), `"Refund amount must be a positive number"`, `"Refund amount
            exceeds the order's remaining total (<max>)"`, `"refundedAt is not a valid
            date"`, or a validation array.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Order not found"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: |
            `error_code: "STORE_SYNCED_READ_ONLY"` — the order is synced from a
            connected store and is read-only via the API.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/orders/{id}/mark-paid:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: markOrderPaid
      tags: [Orders]
      summary: Mark an order paid
      description: |
        Moves `financial_status` to `paid`, records the payment — a completed one-time
        payment for the full order total in the order's currency on the contact's
        payment history — or **links onto an existing payment** via `payment_reference`,
        and fires order-paid automations + the `order.paid` webhook.

        Transition rules: allowed from `pending` and `partially_paid`; an already-`paid`
        order is a **no-op success** (201, no side effects, no duplicate automations);
        from `refunded`/`partially_refunded`/`voided` → 409 `ORDER_ILLEGAL_TRANSITION`
        (refund states are set by recording refunds). `paid_at` is stamped on first
        entry into a paid state and is historical — it is kept through later refund
        states. A cancelled `pending` order can still be marked paid (the `cancelled_at`
        stamp does not affect the ladder).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                payment_reference:
                  type: string
                  maxLength: 255
                  description: |
                    The `external_reference` of an **existing** payment row (e.g. one
                    your system already recorded via `POST /v1/payments`) to link the
                    order onto instead of recording a new payment. Link-only — the
                    payment's amount is never rewritten. Validated **before** linking
                    (see the 404/409 responses).
      responses:
        "201":
          description: The full order (items + refunds + joined contact identity).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: |
            `"Order not found"`, or `error_code: "ORDER_PAYMENT_REFERENCE_NOT_FOUND"` —
            no payment in this workspace matches `payment_reference`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: |
            Coded conflicts (as `error_code`): `ORDER_ILLEGAL_TRANSITION` (from a refund
            state or `voided`), `ORDER_PAYMENT_CONTACT_MISMATCH` (the payment belongs to
            another contact), `ORDER_PAYMENT_NOT_LINKABLE` (not a one-time payment with
            a completed charge), `ORDER_PAYMENT_ALREADY_LINKED` (the order is already
            linked to a **different** payment reference — re-sending the same reference
            is fine), or `STORE_SYNCED_READ_ONLY` (the order is synced from a connected
            store and is read-only via the API).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
              example:
                statusCode: 409
                error: Conflict
                error_code: ORDER_ILLEGAL_TRANSITION
                message: "Illegal status transition refunded → paid. Refund states are set by recording refunds."
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/orders/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: cancelOrder
      tags: [Orders]
      summary: Cancel an order
      description: |
        Stamps `cancelled_at` and fires order-cancelled automations + the
        `order.cancelled` webhook. No request body.

        Cancellation is a **stamp, not a status**: `financial_status` keeps its last
        state — recorded revenue stands until a refund is recorded (cancel a paid order,
        then record refunds as money is returned). **Idempotent:** cancelling an
        already-cancelled order is a 201 no-op (no duplicate automations). A cancelled
        `pending` order can still be marked paid later — cancel is workflow state on the
        fulfillment side, not a financial transition.
      responses:
        "201":
          description: |
            The full order (items + refunds + joined contact identity) with
            `cancelled_at` set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Order not found"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: |
            `error_code: "STORE_SYNCED_READ_ONLY"` — the order is synced from a
            connected store and is read-only via the API.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Emails ─────────────────────────────
  /v1/emails:
    post:
      operationId: sendEmail
      tags: [Emails]
      summary: Send a transactional email (idempotent)
      description: |
        Sends a single transactional email through your workspace's verified sending
        domain. **Raw-content endpoint**: your HTML/text is delivered verbatim — no
        marketing footer, no unsubscribe link, and no tracking is injected unless you opt
        in. Requires at least one verified sending domain with a sender profile configured
        in the app.

        Rate limit: **300 requests / minute per key** (higher than the API default).
        Errors on this endpoint use the structured envelope `{"error": {"code","message"}}`.

        **Idempotency:** the `idempotency_key` is claimed before any quota/deliverability
        gate, so two concurrent requests with the same key can't both send. Replaying a
        resolved key returns **200** with `duplicate: true`. Validation failures and most
        pre-send errors release the key. At-least-once corner case: in a rare
        crash-and-retry overlap right after a successful provider handoff, the message can
        be delivered twice while your retry receives `200 duplicate: true` — design
        receivers so a duplicate email is acceptable.

        **Suppressed recipients are a 2xx, not an error** — check `status` in the body; do
        not treat 2xx alone as "delivered to the provider". The suppression reason is
        deliberately coarse.

        **Tracking (opt-in):** `opens` appends a 1×1 pixel to the HTML part; `clicks` wraps
        absolute http(s) links in the HTML part in a first-party redirect that forwards to
        your URL verbatim (`mailto:`, in-page anchors, relative URLs, and extremely long
        URLs are never wrapped). The plain-text part is never modified. If the deployment
        lacks a usable signing secret for tracking links, the message is sent untracked
        rather than failing. Open/click events arrive via the opt-in
        `email.opened`/`email.clicked` webhooks (first open / first click per send only).

        Consent is your responsibility: this endpoint neither checks marketing consent nor
        creates oToK contact records for recipients. The suppression list is still enforced
        (fail-closed).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailSendRequest"
      responses:
        "201":
          description: This request performed the send (fresh idempotency key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailSendResult"
        "200":
          description: "Idempotent replay of an already-processed key (`duplicate: true`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailSendResult"
        "400":
          description: |
            Request-shape violations (standard validation body), or `validation_failed`
            (structured envelope): both `html` and `text` missing; disallowed or invalid
            custom header; `metadata` over 2048 bytes.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/DomainError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: "`quota_exceeded` — monthly email quota exhausted; resets at the next billing period or after an upgrade."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "`sender_profile_not_found` — `sender_profile_id` not found in this workspace."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: |
            `send_in_progress` — same idempotency key currently processing; retry shortly
            (a claim stuck by a crash is automatically recoverable after ~3 minutes).
            Or `no_verified_sender` — no sender profile with a verified domain.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "422":
          description: "`invalid_recipient` — `to` is not a valid email address; permanent, do not retry unchanged."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          description: |
            `warming_cap_exceeded` (structured envelope) — domain-warming daily cap
            reached, transient, retry after the daily reset — or the standard rate-limit
            body (300/min per key); honor `Retry-After`.
          headers:
            Retry-After:
              description: Seconds to wait before retrying (rate-limit case).
              schema:
                type: string
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/DomainError"
                  - $ref: "#/components/schemas/FrameworkError"
        "502":
          description: |
            `provider_error` — the email provider rejected the send; the message includes
            only a coarse signal. The idempotency key is released — safe to retry with the
            same key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "503":
          description: "`workspace_paused` — workspace email sending is paused by deliverability protection; resolve in the app."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"

  # ───────────────────────────── Suppressions ─────────────────────────────
  /v1/suppressions:
    get:
      operationId: listSuppressions
      tags: [Suppressions]
      summary: List suppressions
      description: |
        The workspace's email suppression rows, newest first. Workspace-scoped rows
        only — the HQ-managed global list is enforced at send time but is never
        returned here.
      parameters:
        - name: email
          in: query
          description: Exact-match filter (case-insensitive).
          schema:
            type: string
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
      responses:
        "200":
          description: Paginated suppression list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Suppression"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: addSuppression
      tags: [Suppressions]
      summary: Suppress an email address (idempotent)
      description: |
        Blocks every future send to this address, independent of consent — **adding a
        suppression does NOT change the contact's consent state**; the two compose at
        send time. Re-adding an already-suppressed address returns the existing row
        with `duplicate: true` (201 for both outcomes).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SuppressionCreate"
      responses:
        "201":
          description: "The suppression row (existing when `duplicate: true`)."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Suppression"
                  - type: object
                    required: [duplicate]
                    properties:
                      duplicate:
                        type: boolean
                        description: |
                          `false` when this request created the row; `true` when the
                          address was already suppressed (the existing row is returned).
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/suppressions/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    delete:
      operationId: deleteSuppression
      tags: [Suppressions]
      summary: Remove a suppression
      description: |
        Lifts this workspace's suppression only — **it does NOT resubscribe anyone**
        (consent state is unchanged), and HQ-managed global rows cannot be removed
        through the API.
      responses:
        "204":
          description: Removed — no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`suppression_not_found` — unknown id, another workspace's row, or a global row."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Audiences ─────────────────────────────
  /v1/audiences:
    get:
      operationId: listAudiences
      tags: [Audiences]
      summary: List audiences
      description: |
        Read-only discovery of the workspace's saved audiences — the reusable targeting
        selectors `POST /v1/campaigns` and `POST /v1/email-campaigns` accept as
        `audience_id`. Newest first. Rows carry the summary columns plus the advisory
        `last_count`/`last_counted_at` size cache — **never the stored `definition`**
        (see the schema). Audiences are managed in-app and are not writable through the
        public API. An empty `kind` value (`?kind=`) is treated as absent; an unknown
        value returns 400.
      parameters:
        - name: kind
          in: query
          description: |
            Exact kind filter. Unknown values return 400 (`"Invalid kind: must be one
            of dynamic, static"`); empty is treated as absent.
          schema:
            type: string
            enum: [dynamic, static]
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated audience list (rows never include the definition).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/AudienceSummary"
        "400":
          description: |
            An unknown `kind`, or a malformed `limit`/`offset`
            (`"Invalid limit: must be a non-negative integer"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ─────────────────────────── Sender profiles ───────────────────────────
  /v1/sender-profiles:
    get:
      operationId: listSenderProfiles
      tags: [Sender profiles]
      summary: List email sender profiles
      description: |
        Read-only discovery of the workspace's email from-identities — the selectors
        `POST /v1/email-campaigns` accepts as `sender_profile_id`. Newest first. Each
        row carries the from identity (`from_name`, the composed `from_email`,
        `reply_to`), the delivery provider, the default flag, and the sending-domain
        linkage with its verification status — prefer profiles with `verified: true`
        when creating campaigns (see the schema). DKIM/DNS material is never returned.
        Profiles are managed in-app and are not writable through the public API.
      parameters:
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated sender-profile list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/SenderProfile"
        "400":
          description: |
            A malformed `limit`/`offset`
            (`"Invalid limit: must be a non-negative integer"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Email campaigns ─────────────────────────────
  /v1/email-campaigns:
    get:
      operationId: listEmailCampaigns
      tags: [Email campaigns]
      summary: List email campaigns
      description: |
        Newest first. **List rows omit the content columns** (`design_json`,
        `compiled_html`, `compiled_styles`, `plain_text`) and the in-app-only A/B fields —
        fetch a single campaign for the content. Delivery counters are included on list
        rows. An empty `status` value (`?status=`) is treated as absent; an unknown value
        returns 400.
      parameters:
        - name: status
          in: query
          description: |
            Exact status filter. Unknown values return 400 (`"Invalid status: must be one
            of draft, scheduled, sending, paused, sent, failed, cancelled"`); empty is
            treated as absent.
          schema:
            type: string
            enum: [draft, scheduled, sending, paused, sent, failed, cancelled]
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated campaign list (rows omit the content columns).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/EmailCampaign"
        "400":
          description: |
            An unknown `status`, or a malformed `limit`/`offset`
            (`"Invalid limit: must be a non-negative integer"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: upsertEmailCampaign
      tags: [Email campaigns]
      summary: Create a draft campaign (idempotent upsert via `external_reference`)
      description: |
        Creates a **draft** broadcast email campaign. `content` (see the **Content
        input** schema) maps to the campaign's email
        body and **compiles immediately** through the same pipeline the in-app launch
        gate runs — the response's `compile: {ok, errors, warnings}` envelope reports the
        result up front, not at send time. The campaign does not send until you call
        `POST /v1/email-campaigns/{id}/send` or `…/schedule`.

        Targeting is optional and validated at write time: `audience_id` must belong to
        your workspace, `audience_filters` is structurally validated and test-compiled
        (failures return 400 with an `errors` array). At send time the audience can only
        **narrow** the built-in email send-eligibility baseline (opted in, not
        suppressed) — never widen it. With no targeting at all, the campaign goes to
        every eligible contact — check `GET /v1/email-campaigns/{id}/estimate` before
        sending.

        **Idempotency:** when `external_reference` matches an existing campaign in the
        workspace, the response carries `duplicate: true` and:

        - while the campaign is still `draft`/`scheduled`, the call **updates** its
          fields exactly as a PATCH would — never `status` or `scheduled_at`;
        - once the launch has claimed it (`sending`/`sent`/`failed`/…), the campaign is
          returned **verbatim** — nothing is mutated, nothing is recompiled, and the
          response carries no `compile` envelope.

        The response is **201 in both cases**. One reference maps to one campaign per
        workspace. A/B testing is deliberately not exposed on the public API — create
        A/B campaigns in-app.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailCampaignCreate"
      responses:
        "201":
          description: |
            The campaign (created — `duplicate: false` — or matched by
            `external_reference` — `duplicate: true`), with the `compile` envelope
            (omitted only on a post-launch verbatim replay).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCampaignWriteResult"
        "400":
          description: |
            Request-shape violations (standard validation body), `"Invalid
            audience_filters definition"` (body includes an `errors` array), or a coded
            domain error (`{"error": {"code", "message"}}`): `invalid_content` (zero or
            two-plus content sources, malformed markdown/blocks/design_json, oversized
            content), `unknown_snippet` (a `::snippet[…]` reference or snippet block that
            matched no workspace snippet — the message lists the available snippet
            names), or `sender_profile_not_found`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/DomainError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Audience not found"` — `audience_id` not in this workspace.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/email-campaigns/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getEmailCampaign
      tags: [Email campaigns]
      summary: Get one campaign
      responses:
        "200":
          description: |
            The full campaign — including `design_json`, the compiled content columns,
            and the delivery counters. No `compile` envelope on plain reads.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCampaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`campaign_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: campaign_not_found
                  message: Email campaign not found
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateEmailCampaign
      tags: [Email campaigns]
      summary: Update a draft/scheduled campaign
      description: |
        Same field set as create minus `external_reference`; only present fields are
        touched, and the nullable fields clear on an explicit `null`. A `content` change
        recompiles (the response carries a fresh `compile` envelope) and **detaches any
        in-app template** the campaign referenced — the patched content is what sends.
        Campaigns the launch already claimed (`sending`/`sent`/`failed`/…) answer 409
        `campaign_not_editable`; a scheduled campaign stays scheduled (PATCH never
        touches `status` or `scheduled_at` — use `…/schedule` to move the launch time).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailCampaignUpdate"
      responses:
        "200":
          description: "The updated campaign, with the `compile` envelope (`ok: true, errors: [], warnings: []` when `content` was not part of the patch)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCampaignWriteResult"
        "400":
          description: |
            Same as create: request-shape violations, `"Invalid audience_filters
            definition"`, or the coded `invalid_content` / `unknown_snippet` /
            `sender_profile_not_found` domain errors.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/DomainError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`campaign_not_found` — or `"Audience not found"` (framework shape) for a patched `audience_id` not in this workspace.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/DomainError"
                  - $ref: "#/components/schemas/FrameworkError"
        "409":
          description: "`campaign_not_editable` — the campaign is no longer draft/scheduled."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: campaign_not_editable
                  message: "Campaign status is 'sent' — only draft or scheduled campaigns can be edited"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/email-campaigns/{id}/estimate:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: estimateEmailCampaign
      tags: [Email campaigns]
      summary: Estimate the campaign's audience size
      description: |
        Runs the campaign's **stored** targeting through the same resolver pipeline the
        send path uses — the email consent + suppression baseline, then the saved
        audience or ad-hoc filters, contact-group narrowing, and `topic_key` opt-outs —
        so the estimate always matches send-time resolution (minus recipients already
        claimed mid-flight). Update targeting first (PATCH), then re-estimate.
      responses:
        "200":
          description: The estimated recipient count.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AudienceEstimate"
              example:
                estimated_recipients: 1284
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`campaign_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/email-campaigns/{id}/send:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: sendEmailCampaign
      tags: [Email campaigns]
      summary: Launch the campaign now
      description: |
        Draft/scheduled campaigns only. No request body. Marks the campaign scheduled
        for now and drives the launch **synchronously through the exact in-app launch
        gates** — sender readiness (verified domain + legal footer fields), inline
        content compile, and the content lint. On success the response is the campaign
        with its post-launch status (typically `sending`; poll
        `GET /v1/email-campaigns/{id}` for counter progress). A gate failure answers
        **422** with the gate's message and the campaign's final status — the gate marks
        the campaign `failed` as a side effect, so `error.campaign_status` tells you
        where it landed.
      responses:
        "200":
          description: The campaign, with its post-launch status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCampaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`campaign_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: "`campaign_not_sendable` — the campaign is not draft/scheduled."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: campaign_not_sendable
                  message: "Campaign status is 'sent' — only draft or scheduled campaigns can be sent"
        "422":
          description: |
            `launch_failed` — a launch gate rejected the campaign. The error object
            additionally carries `campaign_status` (the campaign's final status after the
            gate ran — normally `failed`). Fix the reported problem (verify the sending
            domain, complete the sender profile's footer fields, repair the content) and
            send again.
          content:
            application/json:
              schema:
                type: object
                title: Launch-failed error envelope
                description: The domain envelope extended with the campaign's final status.
                required: [error]
                properties:
                  error:
                    type: object
                    required: [code, message, campaign_status]
                    properties:
                      code:
                        type: string
                        const: launch_failed
                      message:
                        type: string
                        description: The failing gate's message.
                      campaign_status:
                        type: string
                        description: The campaign's status after the failed launch.
              example:
                error:
                  code: launch_failed
                  message: "Sender profile's domain is not verified"
                  campaign_status: failed
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/email-campaigns/{id}/schedule:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: scheduleEmailCampaign
      tags: [Email campaigns]
      summary: Schedule (or reschedule) a future launch
      description: |
        Draft/scheduled campaigns only. `scheduled_at` is an ISO 8601 UTC instant in the
        future; the every-minute sweep launches the campaign when due (running the same
        launch gates as `…/send`). Calling it again while still scheduled moves the
        launch time.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ScheduleAtRequest"
      responses:
        "200":
          description: "The scheduled campaign (`status: scheduled`, `scheduled_at` set)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCampaign"
        "400":
          description: |
            `invalid_scheduled_at` (domain envelope) — `scheduled_at` parses but is not a
            future instant — or a standard validation body for a value that is not ISO
            8601 at all.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/DomainError"
                  - $ref: "#/components/schemas/FrameworkError"
              examples:
                notFuture:
                  summary: Not a future instant
                  value:
                    error:
                      code: invalid_scheduled_at
                      message: scheduled_at must be a future instant
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`campaign_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: "`campaign_not_schedulable` — the campaign is not draft/scheduled."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/email-campaigns/{id}/unschedule:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: unscheduleEmailCampaign
      tags: [Email campaigns]
      summary: Cancel a scheduled launch (back to draft)
      description: |
        No request body. Conditional on status `scheduled` — when the minute sweep has
        already claimed the campaign for sending, the call answers 409 `already_sending`
        rather than silently no-opping, so you know the sends are going out.
      responses:
        "200":
          description: "The campaign, back in `draft` with `scheduled_at` cleared."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCampaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`campaign_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: |
            `already_sending` — the send sweep already claimed this campaign — or
            `campaign_not_scheduled` — the campaign is not currently scheduled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              examples:
                alreadySending:
                  summary: Claimed by the send sweep
                  value:
                    error:
                      code: already_sending
                      message: The send sweep already claimed this campaign — it is sending
                notScheduled:
                  summary: Not currently scheduled
                  value:
                    error:
                      code: campaign_not_scheduled
                      message: "Campaign status is 'draft' — only scheduled campaigns can be unscheduled"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Newsletters ─────────────────────────────
  /v1/newsletters:
    get:
      operationId: listNewsletters
      tags: [Newsletters]
      summary: List newsletters
      description: |
        Newest first. Each row carries its computed `active_subscriber_count`. List rows
        return the documented summary fields only — fetch a single newsletter for the
        full stored configuration.
      parameters:
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated newsletter list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Newsletter"
        "400":
          description: 'A malformed `limit`/`offset` (`"Invalid limit: must be a non-negative integer"`).'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createNewsletter
      tags: [Newsletters]
      summary: Create a newsletter
      description: |
        A `name` alone suffices — cadence, enrollment policy, and archive settings take
        their in-app defaults (adjust them in the app). Omit `sender_profile_id` to fall
        back to the workspace default profile at send time. Newsletter names are unique
        per workspace (case-insensitive) — a duplicate answers 409 `duplicate_name`.
        Subject to the plan's newsletter cap (403 `PLAN_LIMIT_EXCEEDED` when reached).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewsletterCreate"
      responses:
        "201":
          description: "The created newsletter (`active_subscriber_count: 0`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Newsletter"
        "400":
          description: |
            Request-shape violations (standard validation body) or
            `sender_profile_not_found` (domain envelope).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/DomainError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Plan-wide API access, `error_code: FEATURE_NOT_INCLUDED_IN_PLAN` — the plan
            lacks the Newsletters feature — or `error_code: PLAN_LIMIT_EXCEEDED` — the
            plan's newsletter cap is reached.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/FeatureGateError"
        "409":
          description: "`duplicate_name` — a newsletter with this name already exists (case-insensitive)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: duplicate_name
                  message: A newsletter with this name already exists
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/newsletters/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getNewsletter
      tags: [Newsletters]
      summary: Get one newsletter
      responses:
        "200":
          description: |
            The full stored newsletter (enrollment policy, catch-up cadence, archive
            settings — see the schema note) plus its computed `active_subscriber_count`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Newsletter"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`newsletter_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: newsletter_not_found
                  message: Newsletter not found
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/newsletters/{id}/issues:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: listNewsletterIssues
      tags: [Newsletters]
      summary: List a newsletter's issues
      description: |
        Newest first. **List rows omit the content columns** (`design_json`,
        `compiled_html`, `compiled_styles`, `plain_text`) — fetch a single issue for
        those. An empty `status` value (`?status=`) is treated as absent; an unknown
        value returns 400 (`"Invalid status: must be draft, scheduled or published"`).
      parameters:
        - name: status
          in: query
          description: Exact status filter. Unknown values return 400; empty is treated as absent.
          schema:
            type: string
            enum: [draft, scheduled, published]
        - $ref: "#/components/parameters/ClampedLimitParam"
        - $ref: "#/components/parameters/ClampedOffsetParam"
      responses:
        "200":
          description: Paginated issue list (rows omit the content columns).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/NewsletterIssue"
        "400":
          description: An unknown `status`, or a malformed `limit`/`offset`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`newsletter_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: upsertNewsletterIssue
      tags: [Newsletters]
      summary: Create a draft issue (idempotent upsert via `external_reference`)
      description: |
        Creates a **draft** issue. All fields are optional — an issue can start as an
        empty placeholder — but publishing/scheduling later requires a subject and
        compiled content. `content` (see the **Content input**
        schema) maps to the issue's email
        body and compiles immediately; the response's `compile: {ok, errors, warnings}`
        envelope reports the result.

        **Idempotency:** when `external_reference` matches an existing issue in the
        workspace, the call **updates** that issue's content/fields instead of creating
        a new one and answers `duplicate: true` — a replay never touches `status`,
        `scheduled_at`, or `issue_number`, so re-running an export pipeline can refresh
        a draft's content without re-publishing anything. One reference maps to one
        issue per workspace; a reference that belongs to an issue of a **different**
        newsletter answers 409 `external_reference_in_use`. The response is **201 in
        both cases**.

        Issue numbers are assigned **at publish** (`issue_number` stays `null` on
        drafts): issue #N always means the Nth published issue.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewsletterIssueCreate"
      responses:
        "201":
          description: |
            The issue (created — `duplicate: false` — or matched by
            `external_reference` and updated — `duplicate: true`), with the `compile`
            envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterIssueWriteResult"
        "400":
          description: |
            Request-shape violations (standard validation body) or a coded domain error:
            `invalid_content` (zero or two-plus content sources, malformed
            markdown/blocks/design_json, oversized content) or `unknown_snippet` (the
            message lists the available snippet names).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/DomainError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`newsletter_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: "`external_reference_in_use` — the reference already belongs to an issue of a different newsletter."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: external_reference_in_use
                  message: external_reference already belongs to an issue of a different newsletter
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/newsletter-issues/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getNewsletterIssue
      tags: [Newsletters]
      summary: Get one issue
      responses:
        "200":
          description: |
            The full issue — including `design_json`, `compiled_html`,
            `compiled_styles`, and `plain_text`. No `compile` envelope on plain reads.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterIssue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`issue_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: issue_not_found
                  message: Issue not found
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      operationId: updateNewsletterIssue
      tags: [Newsletters]
      summary: Update an issue
      description: |
        Only present fields are touched; `subject`/`preheader` clear on an explicit
        `null`. A `content` change recompiles (fresh `compile` envelope). **Published
        issues stay editable** — a typo fix recompiles for future catch-up deliveries;
        already-delivered copies are naturally immutable. Content cannot be cleared
        through the API (`content: null` fails validation) — replace it with new
        content, or manage that in-app.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewsletterIssueUpdate"
      responses:
        "200":
          description: "The updated issue, with the `compile` envelope (`ok: true, errors: [], warnings: []` when `content` was not part of the patch)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterIssueWriteResult"
        "400":
          description: |
            Request-shape violations, or the coded `invalid_content` /
            `unknown_snippet` domain errors.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FrameworkError"
                  - $ref: "#/components/schemas/DomainError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`issue_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    delete:
      operationId: deleteNewsletterIssue
      tags: [Newsletters]
      summary: Delete a draft/scheduled issue
      description: |
        Published issues can never be deleted — deleting one would free its issue number
        and corrupt subscriber catch-up cursors. Hide one from the public archive with
        `include_in_archive: false` instead.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    const: true
        "400":
          description: "`issue_published` — published issues cannot be deleted."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: issue_published
                  message: Published issues cannot be deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`issue_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/newsletter-issues/{id}/publish:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: publishNewsletterIssue
      tags: [Newsletters]
      summary: Publish an issue now
      description: |
        No request body. Assigns the next `issue_number` (per-newsletter, monotonic) and
        wakes caught-up subscribers — delivery behaves exactly like an in-app publish:
        caught-up subscribers receive the issue on the next delivery sweep, while new or
        behind subscribers reach it through their catch-up drip. Requires a subject and
        compiled content (409 `issue_missing_content` otherwise). **Idempotent** — an
        already-published issue is returned as-is with a 200.
      responses:
        "200":
          description: "The published issue, with its assigned `issue_number` and `published_at`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterIssue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`issue_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: "`issue_missing_content` — the issue has no subject or no compiled content."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: issue_missing_content
                  message: An issue needs a subject and content before it can be published
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/newsletter-issues/{id}/schedule:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: scheduleNewsletterIssue
      tags: [Newsletters]
      summary: Schedule (or reschedule) a future publish
      description: |
        `scheduled_at` is an ISO 8601 UTC instant in the future; the every-minute sweep
        publishes the issue when due (same effects as `…/publish`). Requires a subject
        and compiled content up front, so the sweep can't hit a content check failure
        later. Calling it again while still scheduled moves the publish time.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ScheduleAtRequest"
      responses:
        "200":
          description: "The scheduled issue (`status: scheduled`, `scheduled_at` set)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterIssue"
        "400":
          description: |
            `invalid_scheduled_at` (domain envelope) — `scheduled_at` parses but is not a
            future instant — or a standard validation body for a value that is not ISO
            8601 at all.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/DomainError"
                  - $ref: "#/components/schemas/FrameworkError"
              examples:
                notFuture:
                  summary: Not a future instant
                  value:
                    error:
                      code: invalid_scheduled_at
                      message: scheduled_at must be a future instant
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`issue_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: |
            `issue_already_published` — published issues cannot be scheduled — or
            `issue_missing_content` — the issue has no subject or no compiled content.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              examples:
                alreadyPublished:
                  summary: Already published
                  value:
                    error:
                      code: issue_already_published
                      message: Issue is already published — published issues cannot be scheduled
                missingContent:
                  summary: No subject or content
                  value:
                    error:
                      code: issue_missing_content
                      message: An issue needs a subject and content before it can be scheduled
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/newsletter-issues/{id}/unschedule:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: unscheduleNewsletterIssue
      tags: [Newsletters]
      summary: Cancel a scheduled publish (back to draft)
      description: No request body. Only currently scheduled issues can be unscheduled.
      responses:
        "200":
          description: "The issue, back in `draft` with `scheduled_at` cleared."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterIssue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: "`issue_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "409":
          description: "`issue_not_scheduled` — the issue is not currently scheduled."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
              example:
                error:
                  code: issue_not_scheduled
                  message: "Issue status is 'draft' — only scheduled issues can be unscheduled"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Webhook endpoints ─────────────────────────────
  /v1/webhook-endpoints:
    get:
      operationId: listWebhookEndpoints
      tags: [Webhook endpoints]
      summary: List endpoints
      description: No parameters (max 3 rows, unpaginated). Secrets are never included.
      responses:
        "200":
          description: The workspace's registered endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Register an endpoint (secret returned once)
      description: |
        Maximum **3 endpoints per workspace** (409 `endpoint_limit_reached`); the cap is
        enforced safely under concurrency.

        > **The `secret` is returned only here, once.** It cannot be retrieved again.
        > There is no rotation endpoint: to rotate, register a new endpoint with the same
        > URL, switch verification to the new secret, then delete the old endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [url]
              properties:
                url:
                  type: string
                  minLength: 1
                  maxLength: 2048
                  description: |
                    `http://` or `https://` only. URLs pointing at private, loopback,
                    link-local, and other reserved IP ranges are rejected
                    (400 `unsafe_url`) — re-checked on every delivery attempt. Prefer
                    `https://`: plain `http://` is accepted but the HMAC signature is then
                    your only integrity protection.
                events:
                  type: array
                  minItems: 1
                  items:
                    $ref: "#/components/schemas/WebhookEventType"
                  description: |
                    Event types to receive. Must be non-empty when present. **Omitted →
                    the three delivery events** (`email.delivered`, `email.bounced`,
                    `email.complained`) — every other family is opt-in and must be
                    listed explicitly: the engagement events
                    `email.opened`/`email.clicked`, the five `order.*` lifecycle
                    events, the four `payment_request.*` lifecycle events, the four
                    contact lifecycle + consent events (`contact.created`,
                    `contact.updated`, `contact.deleted`, `contact.consent_changed`),
                    the inbound message event (`message.received`), the four `deal.*`
                    lifecycle events, the four `booking.*` lifecycle events, the
                    event-attendance event (`event.attendance.changed`), and the form
                    submission event (`form.submitted`). A pre-existing registration
                    never starts receiving a new family unasked. `email.failed` is
                    **deprecated**: still accepted when listed explicitly (the
                    registration succeeds and echoes it in `events`), but it is never
                    delivered.
      responses:
        "201":
          description: The created endpoint, including the one-time `secret`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointWithSecret"
        "400":
          description: "`unsafe_url` — non-http(s) scheme or a reserved/private address."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: "`endpoint_limit_reached` — already at 3 endpoints; delete one first."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/webhook-endpoints/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: |
          Endpoint id. Unlike other routes, a malformed (non-UUID) id returns **404**, not
          400 — deliberately indistinguishable from an unknown id.
        schema:
          type: string
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Delete an endpoint
      description: |
        Deliveries stop immediately; anything still queued for the deleted endpoint is
        dropped.
      responses:
        "204":
          description: Deleted — no body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: |
            `endpoint_not_found` — unknown id, another workspace's id, or a malformed
            (non-UUID) id — deliberately indistinguishable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DomainError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  # ───────────────────────────── Meeting types & bookings ─────────────────────────────
  /v1/meeting-types:
    get:
      operationId: listMeetingTypes
      tags: [Meeting types]
      summary: List meeting types
      description: Standard list conventions apply.
      parameters:
        - $ref: "#/components/parameters/FilterParam"
        - $ref: "#/components/parameters/SortParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
        - $ref: "#/components/parameters/SearchParam"
      responses:
        "200":
          description: Paginated meeting type list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/MeetingType"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/meeting-types/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getMeetingType
      tags: [Meeting types]
      summary: Get one meeting type
      responses:
        "200":
          description: A meeting type object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeetingType"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/meeting-types/{id}/slots:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getMeetingTypeSlots
      tags: [Meeting types]
      summary: Get open slots for a date range
      description: |
        Returns the open start instants for the meeting type — the same availability the
        public booking page offers. Slot instants are UTC ISO timestamps; `timezone` is the
        host schedule's IANA zone.
      parameters:
        - name: from
          in: query
          required: true
          description: Range start (inclusive), ISO 8601.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          description: Range end (exclusive); must be after `from`; range may not exceed 62 days.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Open slots in the range.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SlotsResult"
        "400":
          description: |
            `"Meeting type is not active"`, `"from/to must be valid ISO dates"`,
            `` "`to` must be after `from`" ``, or
            `"Requested range may not exceed 62 days"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/meeting-types/{id}/embed:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getMeetingTypeEmbed
      tags: [Meeting types]
      summary: Get website-embed material
      description: |
        Everything needed to put the booking calendar on your own website: the hosted
        booking page URL, the workspace's publishable embed key, and a ready-to-paste
        snippet. `embed_key` (`bk_…`) is safe in page HTML by design and is NOT the
        secret API key — rotation, the origin allowlist, and the embed on/off switch
        live in the oToK app under Settings → Booking. Bookings made through the embed
        carry `source: "embed"`.
      responses:
        "200":
          description: Embed material for the meeting type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeetingTypeEmbed"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/bookings:
    get:
      operationId: listBookings
      tags: [Bookings]
      summary: List bookings
      description: All filters optional and combined with AND. Malformed values → 400.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [confirmed, cancelled, completed, no_show]
        - name: meeting_type_id
          in: query
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          description: "`start_at >= from` (ISO 8601)."
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: "`start_at <= to` (ISO 8601)."
          schema:
            type: string
            format: date-time
        - name: sort
          in: query
          description: "Default `-start_at`; `-` prefix for descending."
          schema:
            type: string
            default: "-start_at"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/OffsetParam"
      responses:
        "200":
          description: Paginated booking list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Booking"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createBooking
      tags: [Bookings]
      summary: Create a booking
      description: |
        Books a slot. The slot must be open at booking time; slot-taking is race-safe. The
        usual booking side effects run (confirmation email, calendar/Zoom setup where
        configured).

        **Idempotency is server-derived** — you do not send a key. A double submit of the
        same slot + meeting type + contact (+ pinned host, when used) returns the existing
        confirmed booking as a success instead of failing or double-booking — still 201,
        with `duplicate: true` (a fresh create answers `duplicate: false`). For round-robin
        types, a pinned create can never silently replay onto a different host than the
        one pinned.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingCreate"
      responses:
        "201":
          description: |
            The booking object with its `hosts` roster, plus the top-level `duplicate`
            marker (`false` on a fresh create, `true` on a replay).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingCreateResult"
        "400":
          description: |
            `"Either contact_id or an invitee object is required"`, `"Meeting type is not
            active"`, `"start_at must be a valid ISO date"`, `"timezone must be a valid
            IANA time zone id"`, or bad `host_user_id`
            (`"Host pinning is only supported on round-robin meeting types"` / `"The
            pinned host is not an active member of this meeting type's pool"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"No availability schedule configured for this meeting type"` — meeting type misconfigured.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: |
            `error_code: "SLOT_TAKEN"` — the selected time is no longer available (taken
            concurrently, host pool exhausted, pinned host busy); **never forceable** —
            re-fetch slots and pick another time. Or `error_code: "CONTACT_MERGE_REQUIRED"`
            — the invitee's phone and email resolve to two different existing contacts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/bookings/{id}:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    get:
      operationId: getBooking
      tags: [Bookings]
      summary: Get one booking
      responses:
        "200":
          description: A booking object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Booking"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/bookings/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: cancelBooking
      tags: [Bookings]
      summary: Cancel a booking
      description: API cancellations are attributed to the host side.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reason:
                  type: string
                  maxLength: 1000
      responses:
        "201":
          description: |
            The booking with `status: "cancelled"` and
            `cancelled_at`/`cancelled_by`/`cancellation_reason` set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Booking"
        "400":
          description: '`"Only confirmed bookings can be cancelled"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/bookings/{id}/reschedule:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: rescheduleBooking
      tags: [Bookings]
      summary: Move a booking to a new slot
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [start_at]
              properties:
                start_at:
                  type: string
                  format: date-time
                  description: ISO 8601 — a new open slot.
                timezone:
                  type: string
                  maxLength: 64
                  description: Updates the invitee timezone; omitted keeps the existing one.
      responses:
        "201":
          description: |
            The booking with `previous_start_at`, `rescheduled_at`, and an incremented
            `reschedule_count`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Booking"
        "400":
          description: |
            `"Only confirmed bookings can be rescheduled"`, `"start_at must be a valid ISO
            date"`, or `"The meeting type for this booking no longer exists"`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Booking not found"` / `"No availability schedule configured for this meeting type"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: '`error_code: "SLOT_TAKEN"` — the target slot is no longer open.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/bookings/{id}/reassign:
    parameters:
      - $ref: "#/components/parameters/UuidPathId"
    post:
      operationId: reassignBooking
      tags: [Bookings]
      summary: Change the assigned host
      description: |
        Change which host owns a confirmed booking. Reassignment is **pre-start only** and
        not available for collective meeting types (where every pool host attends).

        409 handling: `HOST_UNAVAILABLE` is overridable by repeating the request with
        `"force": true`; `SLOT_TAKEN` (a genuine conflicting booking) is **never
        overridable**, even with `force`; `BOOKING_MODIFIED` means the booking changed
        concurrently — re-read and retry.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                user_id:
                  type: string
                  format: uuid
                  description: |
                    Any **active** workspace member. Omitted → automatic round-robin
                    re-pick excluding the current host (round-robin types only).
                reason:
                  type: string
                  maxLength: 1000
                  description: Recorded on the assignment history.
                force:
                  type: boolean
                  const: true
                  description: Override host-availability failures. Must be literally `true`.
      responses:
        "201":
          description: The booking with its new `hosts` roster.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Booking"
        "400":
          description: |
            State guards (`"Only confirmed bookings can be reassigned"`, `"This booking
            has already started"`), collective type, no-op target, omitted `user_id` on a
            non-round-robin type, or a target host that is not an active workspace member.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/FeatureNotIncluded"
        "404":
          description: '`"Booking not found"` / `"The meeting type for this booking no longer exists"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FrameworkError"
        "409":
          description: |
            `error_code: "HOST_UNAVAILABLE"` (soft availability failure — overridable with
            `force: true`), `"SLOT_TAKEN"` (genuine conflicting booking — never
            overridable), or `"BOOKING_MODIFIED"` (concurrent change — re-read and retry).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

# ───────────────────────────── Webhooks (outbound events) ─────────────────────────────
webhooks:
  email.delivered:
    post:
      operationId: emailDeliveredEvent
      tags: [Webhook endpoints]
      summary: email.delivered — the provider confirmed delivery of an API send
      description: Default-subscription delivery event.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailDeliveredEvent"
      responses:
        "200":
          description: |
            Return any 2xx within 10 seconds to settle the delivery. Non-2xx (including
            3xx — redirects are not followed) counts as a failure and is retried.
  email.bounced:
    post:
      operationId: emailBouncedEvent
      tags: [Webhook endpoints]
      summary: email.bounced — the send bounced
      description: |
        Default-subscription delivery event. `data.bounce_type` is included when known:
        `hard`, `soft`, or `block`.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailBouncedEvent"
            examples:
              hardBounce:
                value:
                  id: b2c3d4e5-f607-1829-3a4b-5c6d7e8f9012
                  type: email.bounced
                  created_at: "2026-07-14T10:05:32.000Z"
                  data:
                    send_id: f0e1d2c3-b4a5-9687-7869-5a4b3c2d1e0f
                    idempotency_key: order-88123-receipt
                    to: customer@example.com
                    reason: 550 5.1.1 user unknown
                    bounce_type: hard
                    metadata:
                      order_id: "88123"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  email.complained:
    post:
      operationId: emailComplainedEvent
      tags: [Webhook endpoints]
      summary: email.complained — the recipient marked the message as spam (feedback loop)
      description: Default-subscription delivery event.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailComplainedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  email.failed:
    post:
      operationId: emailFailedEvent
      tags: [Webhook endpoints]
      summary: email.failed — deprecated (never delivered)
      deprecated: true
      description: |
        **Deprecated — never delivered; nothing produces this event.** It is no longer
        part of the default subscription set. Listing it explicitly at registration still
        succeeds (no error, no warning; it is echoed in `events`) so existing integrations
        keep working, but no delivery will ever arrive. A failing `POST /v1/emails` send
        fails synchronously on the request itself (e.g. `502 provider_error`,
        `409 send_in_progress`) — handle send failures from the send response.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailFailedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  email.opened:
    post:
      operationId: emailOpenedEvent
      tags: [Webhook endpoints]
      summary: email.opened — first open only (opt-in)
      description: |
        Opt-in engagement event: at most one per send, for sends with `tracking.opens`.
        `data.machine_open` is always present — `true` means the open was attributed to an
        automated mail scanner / prefetcher (e.g. Apple Mail privacy) rather than a human.
        Machine opens are forwarded flagged, never dropped.

        A click does **not** emit an implied `email.opened` event — treat a send as opened
        when you've received *either* `email.opened` or `email.clicked`.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailOpenedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  email.clicked:
    post:
      operationId: emailClickedEvent
      tags: [Webhook endpoints]
      summary: email.clicked — first click only (opt-in)
      description: |
        Opt-in engagement event: at most one per send, for sends with `tracking.clicks`.
        `data.url` is always present: the original destination URL (may be an empty string
        in rare cases). Clicks carry no machine flag.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailClickedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  order.created:
    post:
      operationId: orderCreatedEvent
      tags: [Webhook endpoints]
      summary: order.created — an order was created
      description: |
        Opt-in order lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires for every order write source (API, in-app,
        automations), never for historical import ingestion. A paid create also emits
        `order.paid`.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderCreatedEvent"
      responses:
        "200":
          description: |
            Return any 2xx within 10 seconds to settle the delivery. Non-2xx (including
            3xx — redirects are not followed) counts as a failure and is retried.
  order.paid:
    post:
      operationId: orderPaidEvent
      tags: [Webhook endpoints]
      summary: order.paid — the order entered a paid state
      description: |
        Opt-in order lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires when the order enters a paid state: a create with
        `financial_status: "paid"`, or `POST /v1/orders/{id}/mark-paid` (and equivalent
        in-app/automation writes).
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderPaidEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  order.refunded:
    post:
      operationId: orderRefundedEvent
      tags: [Webhook endpoints]
      summary: order.refunded — a refund was recorded on the order
      description: |
        Opt-in order lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. In addition to the shared order `data` fields,
        `data.refund` carries the refund that fired this event.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderRefundedEvent"
            examples:
              partialRefund:
                value:
                  id: a1b2c3d4-e5f6-0718-2930-4a5b6c7d8e9f
                  type: order.refunded
                  created_at: "2026-07-15T09:30:00.000Z"
                  data:
                    order_id: 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9
                    external_id: null
                    number: "1042"
                    platform: api
                    store_connection_id: null
                    financial_status: partially_refunded
                    fulfillment_status: unfulfilled
                    currency: ILS
                    total: 360
                    subtotal: 340
                    discount_total: 0
                    shipping_total: 20
                    tax_total: 0
                    refunded_total: 50
                    coupon_codes: [SUMMER10]
                    item_count: 3
                    first_item_name: Widget
                    placed_at: "2026-07-14T10:00:00.000Z"
                    paid_at: "2026-07-14T10:00:00.000Z"
                    cancelled_at: null
                    refunded_at: "2026-07-15T09:30:00.000Z"
                    created_at: "2026-07-14T10:00:00.000Z"
                    refund:
                      amount: 50
                      external_refund_id: r-1
                      reason: damaged
                      refunded_at: "2026-07-15T09:30:00.000Z"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  order.cancelled:
    post:
      operationId: orderCancelledEvent
      tags: [Webhook endpoints]
      summary: order.cancelled — the order was cancelled
      description: |
        Opt-in order lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Cancellation is the `cancelled_at` stamp — the
        `financial_status` in `data` keeps its last state (cancellation is not a
        financial status).
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderCancelledEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  order.fulfilled:
    post:
      operationId: orderFulfilledEvent
      tags: [Webhook endpoints]
      summary: order.fulfilled — the order was fulfilled
      description: |
        Opt-in order lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fulfillment is recorded in-app — there is no `/v1`
        fulfillment route, so this event only ever originates from non-API writes.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderFulfilledEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  payment_request.created:
    post:
      operationId: paymentRequestCreatedEvent
      tags: [Webhook endpoints]
      summary: payment_request.created — a pay-link was minted
      description: |
        Opt-in pay-link lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires for every hosted pay-link mint (API and in-app).
        **Hosted pay-links only:** direct saved-card charges and internal
        dunning-recovery links never emit `payment_request.*` events.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentRequestCreatedEvent"
      responses:
        "200":
          description: |
            Return any 2xx within 10 seconds to settle the delivery. Non-2xx (including
            3xx — redirects are not followed) counts as a failure and is retried.
  payment_request.paid:
    post:
      operationId: paymentRequestPaidEvent
      tags: [Webhook endpoints]
      summary: payment_request.paid — the payer completed the pay-link
      description: |
        Opt-in pay-link lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires when the payment is verified with the provider,
        including a **late completion** of an already-cancelled link (a payer who was on
        the hosted page when it was cancelled). `data.contact_payment_id` links the
        settled payment ledger row. Test-mode completions fire too — check
        `data.test_mode` before recording revenue.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentRequestPaidEvent"
            examples:
              paidLink:
                value:
                  id: c3d4e5f6-0718-2930-4a5b-6c7d8e9f0a1b
                  type: payment_request.paid
                  created_at: "2026-07-15T11:20:00.000Z"
                  data:
                    payment_request_id: 0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0
                    status: paid
                    contact_id: 9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c
                    deal_id: null
                    provider: sumit
                    amount: 250
                    currency: ILS
                    title: Onboarding session
                    vat_mode: inclusive
                    vat_rate: 18
                    test_mode: false
                    pay_url: https://app.otok.io/pay/pr_k3J9…
                    contact_payment_id: 7b6a5c4d-3e2f-1a0b-9c8d-7e6f5a4b3c2d
                    expires_at: "2026-07-18T09:00:00.000Z"
                    paid_at: "2026-07-15T11:20:00.000Z"
                    cancelled_at: null
                    created_at: "2026-07-15T09:00:00.000Z"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  payment_request.expired:
    post:
      operationId: paymentRequestExpiredEvent
      tags: [Webhook endpoints]
      summary: payment_request.expired — the pay-link passed its deadline unpaid
      description: |
        Opt-in pay-link lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires when a pending link passes `expires_at` unpaid —
        from the expiry sweep, or lazily when the expired link is opened.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentRequestExpiredEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  payment_request.cancelled:
    post:
      operationId: paymentRequestCancelledEvent
      tags: [Webhook endpoints]
      summary: payment_request.cancelled — the pay-link was withdrawn
      description: |
        Opt-in pay-link lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires on `POST /v1/payment-requests/{id}/cancel` and on
        in-app cancels. A payer already on the hosted page can still complete after the
        cancel — a later `payment_request.paid` for the same request supersedes this
        event.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentRequestCancelledEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  contact.created:
    post:
      operationId: contactCreatedEvent
      tags: [Webhook endpoints]
      summary: contact.created — a contact was created
      description: |
        Opt-in contact lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires post-commit at the intentional write seams (API
        upserts that create, in-app creates, form/lead capture, integration syncs) —
        CSV imports, bulk edits, and merge-internal writes are deliberately quiet.
        `data.contact` is a fixed compact projection — fetch the full row with
        `GET /v1/contacts/{id}`. The creation-time consent seed does **not** emit a
        separate `contact.consent_changed` — this event covers the creation.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactCreatedEvent"
      responses:
        "200":
          description: |
            Return any 2xx within 10 seconds to settle the delivery. Non-2xx (including
            3xx — redirects are not followed) counts as a failure and is retried.
  contact.updated:
    post:
      operationId: contactUpdatedEvent
      tags: [Webhook endpoints]
      summary: contact.updated — a contact's fields changed
      description: |
        Opt-in contact lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires post-commit for intentional single-contact writes;
        bulk edits, CSV-import updates, merge-internal writes, and engine/noise writers
        (scoring, touch fields, tracking rollups) are deliberately quiet.
        `data.changed_fields` names what changed — the same field list the in-app
        Activity timeline records, including `tags`/`groups` junction keys and
        `custom_fields.<key>` entries. `data.contact` stays the compact core — never
        junction arrays; fetch the full row with `GET /v1/contacts/{id}`.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactUpdatedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  contact.deleted:
    post:
      operationId: contactDeletedEvent
      tags: [Webhook endpoints]
      summary: contact.deleted — a contact was deleted
      description: |
        Opt-in contact lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Carries last-known identifiers only (the row is gone) —
        key your mirror off `data.contact_id`. Bulk deletes emit one event per contact;
        **contact merges are fully silent** (a merge is not a deletion — the absorbed
        contact emits nothing).
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactDeletedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  contact.consent_changed:
    post:
      operationId: contactConsentChangedEvent
      tags: [Webhook endpoints]
      summary: contact.consent_changed — a channel's consent state changed
      description: |
        Opt-in consent event — delivered only to endpoints that list it explicitly in
        `events`. Fires on real consent-state transitions (opt-in, unsubscribe,
        resubscribe) plus deliverability-driven suppress escalations, for every consent
        surface (API, forms, unsubscribe pages, in-app edits). Quiet by design: the
        contact-create consent seed (see `contact.created`), same-state re-assertions,
        double-opt-in ceremony markers, and preference-center metadata updates do not
        emit.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactConsentChangedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  message.received:
    post:
      operationId: messageReceivedEvent
      tags: [Webhook endpoints]
      summary: message.received — an inbound WhatsApp message arrived
      description: |
        Opt-in inbound-message event — delivered only to endpoints that list it
        explicitly in `events`. Real WhatsApp inbound only, exactly once per WhatsApp
        message (post-dedup): reactions, messages from blocked contacts, and WhatsApp
        coexistence echoes/history imports are all silent. Media rides as **metadata
        only** (`mime_type`, `filename`, `bytes`) — never presigned URLs or storage
        keys.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageReceivedEvent"
            examples:
              imageWithCaption:
                value:
                  id: d4e5f607-1829-3a4b-5c6d-7e8f90123456
                  type: message.received
                  created_at: "2026-07-16T08:12:05.000Z"
                  data:
                    message_id: 1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809
                    conversation_id: 2b3c4d5e-6f70-8192-a3b4-c5d6e7f8091a
                    contact_id: 9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c
                    channel: whatsapp_api
                    type: image
                    text: Here is the signed form
                    media:
                      mime_type: image/jpeg
                      filename: signed-form.jpg
                      bytes: 482113
                    wa_message_id: wamid.HBgMOTcyNTA1NTU1NTU1FQIAEhg=
                    timestamp: "2026-07-16T08:12:03.000Z"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  deal.created:
    post:
      operationId: dealCreatedEvent
      tags: [Webhook endpoints]
      summary: deal.created — a deal was created
      description: |
        Opt-in deal lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires for **every** write source — manual, API,
        automations, and Salesforce sync — `data.source` says which.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DealCreatedEvent"
      responses:
        "200":
          description: |
            Return any 2xx within 10 seconds to settle the delivery. Non-2xx (including
            3xx — redirects are not followed) counts as a failure and is retried.
  deal.stage_changed:
    post:
      operationId: dealStageChangedEvent
      tags: [Webhook endpoints]
      summary: deal.stage_changed — a deal moved to another stage
      description: |
        Opt-in deal lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. `data.from_stage_id`/`data.from_stage_name` carry the
        origin stage — they are `null` on every other deal event.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DealStageChangedEvent"
            examples:
              stageMove:
                value:
                  id: e5f60718-293a-4b5c-6d7e-8f9012345678
                  type: deal.stage_changed
                  created_at: "2026-07-16T09:00:00.000Z"
                  data:
                    deal_id: 3c4d5e6f-7081-92a3-b4c5-d6e7f8091a2b
                    contact_id: 9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c
                    pipeline_id: 4d5e6f70-8192-a3b4-c5d6-e7f8091a2b3c
                    pipeline_name: Sales
                    stage_id: 5e6f7081-92a3-b4c5-d6e7-f8091a2b3c4d
                    stage_name: Negotiation
                    from_stage_id: 6f708192-a3b4-c5d6-e7f8-091a2b3c4d5e
                    from_stage_name: Qualified
                    status: open
                    title: Onboarding package
                    amount: 3600
                    currency: ILS
                    owner_user_id: 708192a3-b4c5-d6e7-f809-1a2b3c4d5e6f
                    external_reference: crm-9912
                    source: api
                    expected_close_at: "2026-08-01T00:00:00.000Z"
                    closed_at: null
                    lost_reason: null
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  deal.won:
    post:
      operationId: dealWonEvent
      tags: [Webhook endpoints]
      summary: deal.won — a deal was marked won
      description: |
        Opt-in deal lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. `data.status` is `won` and `data.closed_at` is stamped;
        the deal keeps its last stage.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DealWonEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  deal.lost:
    post:
      operationId: dealLostEvent
      tags: [Webhook endpoints]
      summary: deal.lost — a deal was marked lost
      description: |
        Opt-in deal lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. `data.status` is `lost`; `data.lost_reason` carries the
        stored reason (or `null`).
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DealLostEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  booking.created:
    post:
      operationId: bookingCreatedEvent
      tags: [Webhook endpoints]
      summary: booking.created — a booking was made
      description: |
        Opt-in booking lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. Fires for every booking source — `data.source` says
        which. `booking.completed` / `booking.no_show` deliberately do not exist as
        webhooks: those statuses are sweep-derived, not user actions.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingCreatedEvent"
      responses:
        "200":
          description: |
            Return any 2xx within 10 seconds to settle the delivery. Non-2xx (including
            3xx — redirects are not followed) counts as a failure and is retried.
  booking.rescheduled:
    post:
      operationId: bookingRescheduledEvent
      tags: [Webhook endpoints]
      summary: booking.rescheduled — a booking moved to a new time
      description: |
        Opt-in booking lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. `data.start_at`/`data.end_at` are the NEW times.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingRescheduledEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  booking.cancelled:
    post:
      operationId: bookingCancelledEvent
      tags: [Webhook endpoints]
      summary: booking.cancelled — a booking was cancelled
      description: |
        Opt-in booking lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. `data.cancelled_by` and `data.cancel_reason` carry who
        cancelled and why (when recorded).
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingCancelledEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  booking.reassigned:
    post:
      operationId: bookingReassignedEvent
      tags: [Webhook endpoints]
      summary: booking.reassigned — the booking's host changed
      description: |
        Opt-in booking lifecycle event — delivered only to endpoints that list it
        explicitly in `events`. `data.previous_host_name` carries the outgoing host —
        it is `null` on every other booking event.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingReassignedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  event.attendance.changed:
    post:
      operationId: eventAttendanceChangedEvent
      tags: [Webhook endpoints]
      summary: event.attendance.changed — an attendee's status changed
      description: |
        Opt-in event-attendance event — delivered only to endpoints that list it
        explicitly in `events`. ONE event type for the whole family — filter on
        `data.status`; `data.previous_status` carries the transition (`null` for fresh
        registrations and for set-based bulk status updates, which have no per-row
        prior state). Fires per attendance row at the writer seams (single edits, bulk
        operations, automation actions).
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EventAttendanceChangedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.
  form.submitted:
    post:
      operationId: formSubmittedEvent
      tags: [Webhook endpoints]
      summary: form.submitted — a form was submitted
      description: |
        Opt-in form event — delivered only to endpoints that list it explicitly in
        `events`. ONE event type for every surface — `data.origin` distinguishes a
        standalone form embed (`form`), a landing-page form (`landing_page`), and an
        on-site popup (`popup`). Fires post-persist even when no contact was resolved
        (`data.contact_id` is then `null`); `data.fields` carries the submitted answers
        keyed by form field ids.
      parameters:
        - $ref: "#/components/parameters/WebhookEventHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FormSubmittedEvent"
      responses:
        "200":
          description: Return any 2xx within 10 seconds to settle the delivery.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: otok_live_ API key
      description: |
        Workspace API key sent as a bearer token: `Authorization: Bearer otok_live_...`.
        Keys are created in Settings → Developers, shown exactly once, and grant access to
        the entire `/v1` surface on behalf of their workspace (no per-key scopes). Writes
        are attributed to the workspace, not to any individual agent. Missing/non-Bearer
        header → 401 "Missing API key"; malformed, unknown, or revoked key → 401 "Invalid
        API key".

  parameters:
    UuidPathId:
      name: id
      in: path
      required: true
      description: Record UUID. A non-UUID value returns 400.
      schema:
        type: string
        format: uuid
    FilterParam:
      name: filter
      in: query
      description: |
        String-encoded JSON object. Invalid JSON → 400 `"Invalid filter: must be valid
        JSON"`; a non-object → 400 `"Invalid filter: must be a JSON object"`. Any
        `workspace_id` in the filter is ignored and replaced with your authenticated
        workspace.

        Grammar: exact match (`{"lifecycle_stage": "lead"}`; array values match any
        member), `$where` (nested condition tree, max 100 nodes, max 200 items per array
        value), and `$gt` (the only top-level comparison shortcut). Contacts additionally
        support `$jsonb_contains`, `$event_attendance`, and virtual consent fields. Note:
        the full set of `$where` operators is not exhaustively enumerated in the public
        docs — see the Getting Started guide for the documented examples.

        Filter **values** are type-checked against the target field before the query
        runs — a mistyped value returns 400 `Invalid filter value for "<field>": …` (per
        field kind: not a date / not a UUID / must be one of <allowed values> / not a
        number / not a boolean). Date-only strings (`"2026-01-01"`), numeric strings
        (`"125"`), and `"true"`/`"false"` strings are accepted and coerced. Substring
        operators (`contains`, `not_contains`, `starts_with`, `ends_with`) in `$where`
        trees are rejected on non-text fields (400 `Invalid filter on "<field>": substring
        operators (contains, starts_with, ends_with) only apply to text fields`).
      schema:
        type: string
        default: "{}"
      example: '{"lifecycle_stage":"lead"}'
    SortParam:
      name: sort
      in: query
      description: "Field name; `-` prefix for descending."
      schema:
        type: string
        default: "-created_at"
    LimitParam:
      name: limit
      in: query
      description: Hard cap 500. Non-integer or negative → 400.
      schema:
        type: integer
        minimum: 0
        maximum: 500
        default: 50
    OffsetParam:
      name: offset
      in: query
      description: Non-integer or negative → 400.
      schema:
        type: integer
        minimum: 0
        default: 0
    SearchParam:
      name: search
      in: query
      description: Free-text search (fields vary per resource).
      schema:
        type: string
    ClampedLimitParam:
      name: limit
      in: query
      description: |
        Default 25, cap 100. Absent or empty values default; a malformed value returns
        400 `"Invalid limit: must be a non-negative integer"`.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    ClampedOffsetParam:
      name: offset
      in: query
      description: |
        Default 0, min 0. Absent or empty values default; a malformed value returns 400
        `"Invalid offset: must be a non-negative integer"`.
      schema:
        type: integer
        minimum: 0
        default: 0
    LenientLimitParam:
      name: limit
      in: query
      description: |
        Default 25, cap 100, floor 1. Unlike `GET /v1/deals`/`GET /v1/payments`, paging
        here never returns 400 — absent, empty, malformed, or zero values silently
        default to 25, and out-of-range values are clamped into 1–100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    LenientOffsetParam:
      name: offset
      in: query
      description: |
        Default 0, floor 0. Absent, empty, or malformed values silently default to 0;
        negative values are clamped to 0 — never a 400.
      schema:
        type: integer
        minimum: 0
        default: 0
    WebhookEventHeader:
      name: X-Otok-Event
      in: header
      required: true
      description: The event type (e.g. `email.bounced`).
      schema:
        $ref: "#/components/schemas/WebhookEventType"
    WebhookEventIdHeader:
      name: X-Otok-Event-Id
      in: header
      required: true
      description: |
        The event id (same as the payload `id`). Stable across retries and shared across
        your endpoints — use it as your dedup key.
      schema:
        type: string
        format: uuid
    WebhookSignatureHeader:
      name: X-Otok-Signature
      in: header
      required: true
      description: |
        `t=<unix seconds>,v1=<hex HMAC>`. The signed payload is the string
        `"<t>" + "." + <raw request body>` (the exact bytes on the wire). `v1` is the
        lowercase hex HMAC-SHA256 of that string, keyed with the **entire secret string
        including the `whsec_` prefix**. `t` is the unix-seconds timestamp of **this
        delivery attempt** — retries carry fresh timestamps, so you can enforce a replay
        window. Exactly one `v1` value is sent per request. Compare with a timing-safe
        comparison.
      schema:
        type: string
        pattern: '^t=\d+,v1=[0-9a-f]{64}$'

  responses:
    BadRequest:
      description: |
        Invalid request — validation failure, unknown body fields, invalid
        `filter`/`limit`/`offset`, a mistyped filter value
        (`Invalid filter value for "<field>": …`), or a non-UUID path id.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/FrameworkError"
    Unauthorized:
      description: |
        Missing or non-`Bearer` `Authorization` header ("Missing API key"), or a
        malformed, unknown, or revoked key ("Invalid API key").
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/FrameworkError"
    Forbidden:
      description: |
        The workspace's plan lacks API access (Growth or higher required).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/FrameworkError"
          example:
            statusCode: 403
            message: The REST API requires a plan with API access (Growth or higher)
            error: Forbidden
    FeatureNotIncluded:
      description: |
        The workspace's plan lacks the feature this resource group requires — deals,
        payments (payments + contact documents), workspace_payments (payment requests),
        orders, campaigns, email_marketing (email campaigns + suppressions + sender
        profiles), newsletters, or booking — and answers with the
        `FEATURE_NOT_INCLUDED_IN_PLAN` body (note: no `statusCode` field; the message
        embeds the lowercase feature id, so key on `error_code`). A plan without API
        access at all (Growth or higher required) answers with the standard shape instead.
      content:
        application/json:
          schema:
            anyOf:
              - $ref: "#/components/schemas/FeatureGateError"
              - $ref: "#/components/schemas/FrameworkError"
          examples:
            featureNotIncluded:
              summary: Plan lacks the required feature
              value:
                message: "Your current plan does not include access to this feature: deals. Please upgrade your plan."
                error_code: FEATURE_NOT_INCLUDED_IN_PLAN
            planWithoutApiAccess:
              summary: Plan lacks API access
              value:
                statusCode: 403
                message: The REST API requires a plan with API access (Growth or higher)
                error: Forbidden
    NotFound:
      description: Record not found in this workspace.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/FrameworkError"
    TooManyRequests:
      description: |
        Rate limit exceeded (100 requests/min per key by default; 300/min per source IP
        across all API-key traffic). Honor `Retry-After` with exponential backoff.
      headers:
        Retry-After:
          description: How long to wait before retrying.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/FrameworkError"
    ContactMergeRequired:
      description: |
        `error_code: "CONTACT_MERGE_REQUIRED"` — the provided phone and email belong to two
        different existing contacts. A merge request has been opened (repeated conflicting
        requests for the same pair reuse it); resolve it in the app, then retry. The
        response includes `merge_request_id`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/CodedError"
          example:
            statusCode: 409
            error: Conflict
            error_code: CONTACT_MERGE_REQUIRED
            merge_request_id: 3a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
            message: "The provided phone and email belong to two different existing contacts. A merge request has been opened — resolve it in the app, then retry."

  schemas:
    # ── Errors ──
    FrameworkError:
      type: object
      title: Framework error
      description: |
        Standard error shape used by validation failures, authentication, authorization,
        rate limiting, and most resource errors. `message` is a string for most errors and
        an array of per-field messages for request-validation failures.
      properties:
        statusCode:
          type: integer
        message:
          oneOf:
            - type: string
            - type: array
              items:
                type: string
        error:
          type: string
          description: HTTP reason phrase (e.g. "Bad Request").
        error_code:
          type: string
          description: |
            Machine-readable code present on some 4xx responses (deals, payments,
            orders, bookings, contacts). Prefer it over matching on message text when
            documented.
      additionalProperties: true
    CodedError:
      title: Coded error
      description: |
        A framework error carrying a machine-readable `error_code` (and occasionally extra
        fields such as `merge_request_id`).
      allOf:
        - $ref: "#/components/schemas/FrameworkError"
        - type: object
          required: [error_code]
          properties:
            error_code:
              type: string
            merge_request_id:
              type: string
              format: uuid
              description: Present on `CONTACT_MERGE_REQUIRED` responses.
    FeatureGateError:
      type: object
      title: Feature-gate error
      description: |
        Returned with `403` when the workspace's plan lacks the feature a gated resource
        group requires (deals/pipelines → `deals`, payments and contact documents →
        `payments`, payment requests → `workspace_payments`, orders →
        `orders`, campaigns → `campaigns`, email campaigns → `email_marketing`,
        newsletters → `newsletters`, bookings/meeting-types → `booking`). Unlike
        the standard framework
        shape there is **no `statusCode` field**. The message embeds the lowercase
        plan-feature id, not the product display name — key on `error_code`, not on the
        message text.
      required: [message, error_code]
      properties:
        message:
          type: string
          examples:
            - "Your current plan does not include access to this feature: deals. Please upgrade your plan."
        error_code:
          type: string
          const: FEATURE_NOT_INCLUDED_IN_PLAN
    DomainError:
      type: object
      title: Domain error envelope
      description: |
        Structured envelope used for business-rule failures on the email and
        webhook-endpoint APIs. Key on `error.code` — the `message` text is informative and
        may change.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              examples: [quota_exceeded, unsafe_url, send_in_progress]
            message:
              type: string

    # ── Shared ──
    ListEnvelope:
      type: object
      title: List envelope
      description: |
        Consistent list envelope. `total` is the count of all matches, so
        `offset + limit < total` means there are more pages.
      required: [data, total, limit, offset]
      properties:
        data:
          type: array
          items: {}
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    # ── Contacts ──
    Contact:
      type: object
      title: Contact
      description: |
        The full contact record plus computed fields. Note: the public docs do not
        exhaustively enumerate every stored column returned on reads — this schema models
        the documented writable fields plus the documented computed fields, and allows
        additional properties.

        **Round-trip warning:** `tags`/`groups` are returned as **ids** here but accepted
        as **names** on writes. Never echo the ids from a GET back into a write —
        unrecognized names are auto-created.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        phone:
          type: [string, "null"]
          description: E.164-normalized.
        name:
          type: [string, "null"]
        first_name:
          type: [string, "null"]
        last_name:
          type: [string, "null"]
        email:
          type: [string, "null"]
        avatar_url:
          type: [string, "null"]
        notes:
          type: [string, "null"]
        lifecycle_stage:
          type: [string, "null"]
          enum: [lead, prospect, customer, inactive, archived, null]
        source:
          type: [string, "null"]
          enum: [manual, import, widget, campaign, api, form, null]
        block_state:
          type: [string, "null"]
          enum: [none, workspace, global, null]
        company_name:
          type: [string, "null"]
        vat_number:
          type: [string, "null"]
        job_title:
          type: [string, "null"]
        industry:
          type: [string, "null"]
        company_website:
          type: [string, "null"]
        annual_revenue:
          type: [number, "null"]
        employee_count:
          type: [integer, "null"]
        currency_preference:
          type: [string, "null"]
        address_line1:
          type: [string, "null"]
        address_line2:
          type: [string, "null"]
        city:
          type: [string, "null"]
        state:
          type: [string, "null"]
        country:
          type: [string, "null"]
        postal_code:
          type: [string, "null"]
        gender:
          type: [string, "null"]
          enum: [male, female, other, prefer_not_to_say, null]
        date_of_birth:
          type: [string, "null"]
          format: date
        language:
          type: [string, "null"]
        utm_source:
          type: [string, "null"]
        utm_medium:
          type: [string, "null"]
        utm_campaign:
          type: [string, "null"]
        utm_term:
          type: [string, "null"]
        utm_content:
          type: [string, "null"]
        gclid:
          type: [string, "null"]
        fbclid:
          type: [string, "null"]
        lead_score:
          type: [number, "null"]
          description: |
            Engine-owned while workspace lead scoring is enabled — reads echo the computed
            score.
        linkedin_url:
          type: [string, "null"]
        facebook_url:
          type: [string, "null"]
        instagram_handle:
          type: [string, "null"]
        twitter_handle:
          type: [string, "null"]
        custom_fields:
          type: [object, "null"]
          additionalProperties: true
        tags:
          type: array
          items:
            type: string
            format: uuid
          description: Tag **ids** (names on input — see round-trip warning).
        groups:
          type: array
          items:
            type: string
            format: uuid
          description: Group **ids** (names on input — see round-trip warning).
        event_attendances:
          type: array
          description: |
            `[{id, event_id, status, registered_at, attended_at, unregistered_at}]`. The
            full `status` enum for attendances is not enumerated in the public docs (the
            filter examples show values like "attended").
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              event_id:
                type: string
                format: uuid
              status:
                type: string
              registered_at:
                type: [string, "null"]
                format: date-time
              attended_at:
                type: [string, "null"]
                format: date-time
              unregistered_at:
                type: [string, "null"]
                format: date-time
        whatsapp_subscribed:
          type: boolean
          description: Contacts without a subscription record report `false`.
        email_subscribed:
          type: boolean
          description: Contacts without a subscription record report `false`.
        whatsapp_deliverability:
          type: string
          description: |
            Per-channel deliverability state. The public docs document `"unknown"` for
            contacts without a subscription record but do not enumerate the full value set.
        email_deliverability:
          type: string
          description: |
            Per-channel deliverability state. The public docs document `"unknown"` for
            contacts without a subscription record but do not enumerate the full value set.
        score_band:
          type: [string, "null"]
          enum: [cold, warm, hot, null]
          description: Read-only lead-scoring band.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    ContactUpsertResult:
      title: Contact upsert result
      description: |
        `POST /v1/contacts` response — the full contact object plus the top-level
        `duplicate` marker. Both outcomes return 201.
      allOf:
        - $ref: "#/components/schemas/Contact"
        - type: object
          required: [duplicate]
          properties:
            duplicate:
              type: boolean
              description: |
                `false` when this request created the contact; `true` when it matched an
                existing contact by phone/email and updated it (tags/groups added, not
                replaced).
    ContactWrite:
      type: object
      title: Contact write payload
      description: |
        Payload for `POST /v1/contacts` (upsert — tags/groups **added**) and
        `PATCH /v1/contacts/{id}` (tags/groups **replace** the full set). Every field is
        optional; unknown fields → 400.
      additionalProperties: false
      properties:
        phone:
          type: string
          maxLength: 32
          description: Normalized to E.164 (local numbers use the workspace default country).
        name:
          type: string
          maxLength: 200
        first_name:
          type: string
          maxLength: 100
        last_name:
          type: string
          maxLength: 100
        email:
          type: string
          format: email
          maxLength: 255
        avatar_url:
          type: string
          maxLength: 500
        notes:
          type: string
          maxLength: 5000
        lifecycle_stage:
          type: string
          enum: [lead, prospect, customer, inactive, archived]
        source:
          type: string
          enum: [manual, import, widget, campaign, api, form]
        block_state:
          type: string
          enum: [none, workspace, global]
          description: |
            Changes route through the consent/blocking subsystem. Global blocks cannot be
            lifted via the API. A non-`none` value without a blacklist match is recorded
            with an import block source.
        company_name:
          type: string
          maxLength: 200
        vat_number:
          type: string
          maxLength: 40
        job_title:
          type: string
          maxLength: 120
        industry:
          type: string
          maxLength: 80
        company_website:
          type: string
          maxLength: 500
        annual_revenue:
          type: number
        employee_count:
          type: integer
          minimum: 0
          maximum: 2147483647
        currency_preference:
          type: string
          maxLength: 8
        address_line1:
          type: string
          maxLength: 200
        address_line2:
          type: string
          maxLength: 200
        city:
          type: string
          maxLength: 100
        state:
          type: string
          maxLength: 100
        country:
          type: string
          maxLength: 100
        postal_code:
          type: string
          maxLength: 20
        gender:
          type: string
          enum: [male, female, other, prefer_not_to_say]
        date_of_birth:
          type: string
          format: date
          description: ISO 8601 date.
        language:
          type: string
          maxLength: 12
        utm_source:
          type: string
          maxLength: 200
        utm_medium:
          type: string
          maxLength: 200
        utm_campaign:
          type: string
          maxLength: 200
        utm_term:
          type: string
          maxLength: 200
        utm_content:
          type: string
          maxLength: 200
        gclid:
          type: string
          maxLength: 200
        fbclid:
          type: string
          maxLength: 200
        lead_score:
          type: number
          description: |
            **Engine-owned:** silently ignored while workspace lead scoring is enabled —
            the response echoes the computed score. Writable only when scoring is disabled.
        linkedin_url:
          type: string
          maxLength: 500
        facebook_url:
          type: string
          maxLength: 500
        instagram_handle:
          type: string
          maxLength: 100
        twitter_handle:
          type: string
          maxLength: 100
        custom_fields:
          type: object
          additionalProperties: true
          description: |
            Arbitrary keys; **shallow-merged** into the existing object on update. Set a
            key to `null` to remove it.
        tags:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 100
          description: Tag **names**. Missing tags are auto-created.
        groups:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 100
          description: Group **names**. Missing groups are auto-created.
    ConsentChannelState:
      type: object
      title: Per-channel consent state
      description: |
        One channel of a contact's consent picture. A channel without a stored decision
        reads `consent_state`/`deliverability` `"unknown"` — treat unknown as not
        sendable. `deliverability` is provider-owned (bounce/complaint feedback) and is
        never writable through the API. On the **email** channel the object additionally
        carries the composed send-time suppression verdict (`suppressed` +
        `suppression_reason`): suppression list + blacklist + deliverability — a verdict
        deliberately independent of the consent decision.
      required:
        - consent_state
        - consent_basis
        - consent_source
        - consent_at
        - consent_expires_at
        - deliverability
        - unsubscribed_at
        - complained_at
        - last_bounce_at
      properties:
        consent_state:
          type: string
          enum: [subscribed, unsubscribed, unknown]
          description: The stored consent decision — `unknown` = no decision recorded yet.
        consent_basis:
          type: [string, "null"]
          enum: [express_opt_in, double_opt_in, soft_opt_in, implied, imported, null]
          description: Legal basis of the stored decision.
        consent_source:
          type: [string, "null"]
          description: |
            Provenance of the stored decision — API writes read `api` or
            `api:<your source>`; other surfaces stamp their own (form ids, `manual`, …).
        consent_at:
          type: [string, "null"]
          format: date-time
        consent_expires_at:
          type: [string, "null"]
          format: date-time
        deliverability:
          type: string
          enum: [unknown, deliverable, temporarily_bounced, bounced, complained]
          description: |
            Provider-owned delivery health. `complained` is sticky — consent on that
            channel can never be re-subscribed through the API.
        unsubscribed_at:
          type: [string, "null"]
          format: date-time
        complained_at:
          type: [string, "null"]
          format: date-time
        last_bounce_at:
          type: [string, "null"]
          format: date-time
        suppressed:
          type: boolean
          description: |
            **Email channel only.** The composed send-time verdict — `true` when any of
            the suppression list, the blacklist, or a `bounced`/`complained`
            deliverability blocks sends to this address.
        suppression_reason:
          type: [string, "null"]
          description: |
            **Email channel only.** Why `suppressed` is `true`, prefixed by the layer —
            e.g. `suppression:manual`, `suppression:unsubscribe`, `blacklist:global`,
            `blacklist:workspace`, `deliverability:bounced`, `deliverability:complained`.
            `null` when not suppressed.
      additionalProperties: true
    ContactConsent:
      type: object
      title: Contact consent (both channels)
      description: |
        `GET /v1/contacts/{id}/consent` — the contact's per-channel marketing-consent
        picture. `block_state` is the contact-level block (independent of per-channel
        consent); each channel composes the stored decision with the deliverability
        axis, and the email channel adds the send-time suppression verdict.
      required: [contact_id, block_state, channels]
      properties:
        contact_id:
          type: string
          format: uuid
        block_state:
          type: string
          enum: [none, workspace, global]
          description: Contact-level block state — independent of per-channel consent.
        channels:
          type: object
          required: [whatsapp, email]
          properties:
            whatsapp:
              $ref: "#/components/schemas/ConsentChannelState"
            email:
              allOf:
                - $ref: "#/components/schemas/ConsentChannelState"
                - type: object
                  required: [suppressed, suppression_reason]
      additionalProperties: true
    SetConsentRequest:
      type: object
      title: Set-consent payload
      description: |
        `PUT /v1/contacts/{id}/consent/{channel}` — record a consent decision with its
        provenance (the evidence trail stores source, IP, and user agent).
      additionalProperties: false
      required: [state]
      properties:
        state:
          type: string
          enum: [subscribed, unsubscribed]
          description: |
            The consent decision to record. `"unknown"` is a system state and cannot
            be set.
        basis:
          type: string
          enum: [express_opt_in, double_opt_in, soft_opt_in, implied, imported]
          description: |
            Legal basis of the decision. Defaults to `express_opt_in` when subscribing.
        source:
          type: string
          maxLength: 100
          description: |
            Where the decision came from in your system; recorded as `api:<source>` in
            the consent evidence trail (plain `api` when omitted).
        expires_at:
          type: string
          format: date-time
          description: When this consent expires (ISO 8601).
        ip:
          type: string
          maxLength: 64
          description: End-user IP captured with the decision (evidence trail).
        user_agent:
          type: string
          maxLength: 512
          description: End-user user agent captured with the decision (evidence trail).
    Note:
      type: object
      title: Contact note
      description: |
        Plain-text annotation on a contact. Notes created via the API have no author user
        and are attributed to source `api`. `author_name` is included on list responses
        only.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
        author_user_id:
          type: [string, "null"]
          format: uuid
        author_name:
          type: [string, "null"]
          description: List responses only.
        source:
          type: string
        body:
          type: string
        body_json:
          type: ["object", "null"]
          description: Rich-text body — in-app feature; `null` for API-created notes.
        mentioned_user_ids:
          type: [array, "null"]
          items:
            type: string
            format: uuid
        pinned_at:
          type: [string, "null"]
          format: date-time
        conversation_id:
          type: [string, "null"]
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ── Tags & groups ──
    Tag:
      type: object
      title: Tag
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: [string, "null"]
        type:
          type: string
          enum: [contact, conversation, both]
        usage_count:
          type: integer
          description: Computed — the number of contacts currently carrying the tag.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TagWrite:
      type: object
      title: Tag write payload
      description: For POST, `name` is required; on PATCH all fields are optional.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        color:
          type: string
          maxLength: 20
        type:
          type: string
          enum: [contact, conversation, both]
          default: both
    ContactGroup:
      type: object
      title: Contact group
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: [string, "null"]
        color:
          type: [string, "null"]
        is_system:
          type: boolean
        contact_count:
          type: integer
          description: Computed — the number of member contacts.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ContactGroupWrite:
      type: object
      title: Contact group write payload
      description: For POST, `name` is required; on PATCH all fields are optional.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        description:
          type: string
          maxLength: 500
        color:
          type: string
          maxLength: 20

    # ── Campaigns ──
    Campaign:
      type: object
      title: Campaign (WhatsApp)
      description: |
        List rows omit `variables` and `audience_filters` — fetch a single campaign to read
        them. Note: the public docs list the field names of the full campaign object but do
        not document the type/shape of `audience_baseline`; it is modeled loosely here.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: [string, "null"]
        status:
          type: string
          description: |
            Only `draft` and `scheduled` can be set via the API; execution states are
            system-managed. The full status value set is not enumerated in the public docs.
        type:
          type: string
          enum: [broadcast, drip, triggered]
        template_id:
          type: [string, "null"]
          format: uuid
        template_name:
          type: [string, "null"]
        audience_baseline:
          description: Not documented in detail in the public docs.
          type: [string, "null"]
        audience_id:
          type: [string, "null"]
          format: uuid
        audience_filters:
          type: [object, "null"]
          description: A `$where` condition tree. Omitted on list rows.
        custom_message:
          type: [string, "null"]
        scheduled_at:
          type: [string, "null"]
          format: date-time
        timezone:
          type: [string, "null"]
        started_at:
          type: [string, "null"]
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time
        total_recipients:
          type: [integer, "null"]
        sent_count:
          type: [integer, "null"]
        delivered_count:
          type: [integer, "null"]
        read_count:
          type: [integer, "null"]
        failed_count:
          type: [integer, "null"]
        reply_count:
          type: [integer, "null"]
        pending_retry_count:
          type: [integer, "null"]
        instance_id:
          type: [string, "null"]
          format: uuid
        variables:
          type: [object, "null"]
          description: Template variable mappings. Omitted on list rows.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    CampaignWrite:
      type: object
      title: Campaign write payload
      description: For POST, `name` is required; on PATCH all fields are optional.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        description:
          type: string
          maxLength: 2000
        status:
          type: string
          enum: [draft, scheduled]
          description: |
            `draft`, `scheduled` only — execution states cannot be set via the API.
            Omitted on create → `draft`.
        type:
          type: string
          enum: [broadcast, drip, triggered]
          description: Omitted on create → `broadcast`.
        template_id:
          type: string
          format: uuid
          description: WhatsApp template to send.
        template_name:
          type: string
          maxLength: 512
        audience_id:
          type: string
          format: uuid
          description: A saved audience; takes precedence over `audience_filters`. Must belong to your workspace (404 otherwise).
        audience_filters:
          type: object
          description: |
            A `$where` condition tree (same grammar as the contacts `filter` `$where`);
            validated structurally and test-compiled at write time — failures return 400
            with an `errors` array.
        custom_message:
          type: string
        scheduled_at:
          type: string
          format: date-time
          description: |
            ISO 8601. A date-only value ("2026-07-01") is accepted and interpreted as a
            date; an unparseable value returns 400
            `Invalid date value for "scheduled_at": "<value>"`.
        timezone:
          type: string
          maxLength: 64
          description: Omitted → `UTC`.
        instance_id:
          type: string
          format: uuid
          description: WhatsApp instance (phone number connection) to send from.
        variables:
          type: object
          description: Template variable mappings.
    CampaignExecuteResult:
      type: object
      title: Campaign execute result
      description: |
        Delivered with HTTP 200 when the campaign was queued. Failures do not use this
        shape — they answer 404/409 with the `{"error": {"code", "message"}}` envelope.
      required: [success, message, jobId]
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
        jobId:
          type: string
          description: Per-campaign execution job id (`execute-<campaignId>`).

    # ── Templates ──
    Template:
      type: object
      title: WhatsApp template
      description: |
        Note: the public docs list the row's field names but do not document the item shape
        of `buttons` or the exact value sets of `category`/`status`/`header_type`; they are
        modeled loosely here. Only templates with an approved `status` can be delivered.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        display_name:
          type: [string, "null"]
        category:
          type: [string, "null"]
        language:
          type: [string, "null"]
        status:
          type: [string, "null"]
        header_type:
          type: [string, "null"]
        header_text:
          type: [string, "null"]
        body_text:
          type: [string, "null"]
        body_variables_examples:
          type: [array, "null"]
          items:
            type: string
        footer_text:
          type: [string, "null"]
        buttons:
          type: [array, "null"]
          items:
            type: object
            additionalProperties: true
        meta_template_id:
          type: [string, "null"]
        rejection_reason:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    TemplateSendRequest:
      type: object
      title: Template send payload
      additionalProperties: false
      required: [to]
      properties:
        to:
          type: string
          description: Recipient phone, international format (e.g. `+972501234567`).
        body_variables:
          type: array
          description: |
            Values for the template's body placeholders. `param_name` matches named
            placeholders; otherwise values apply by position. Entries are strictly
            validated on the request — `type` and `text` are required, and unknown
            properties are rejected.
          items:
            type: object
            additionalProperties: false
            required: [type, text]
            properties:
              type:
                type: string
                examples: [text]
              text:
                type: string
              param_name:
                type: string
        header_config:
          type: object
          additionalProperties: false
          required: [type]
          description: |
            `{ "type": "text", "variables": ["..."] }` for text headers, or
            `{ "type": "media", "media_type": "image"|"video"|"document",
            "media_link": "https://..." }` for media headers. Strictly validated on the
            request — unknown properties are rejected.
          properties:
            type:
              type: string
              enum: [text, media]
            variables:
              type: array
              items:
                type: string
            media_type:
              type: string
              enum: [image, video, document]
            media_link:
              type: string
        button_configs:
          type: array
          description: |
            Values for dynamic buttons (e.g. dynamic-URL suffixes). The set of valid
            `type` values is not enumerated in the public docs. Entries are strictly
            validated on the request — **`parameters` is required**; send `[]` for a
            button with no dynamic values.
          items:
            type: object
            additionalProperties: false
            required: [type, index, parameters]
            properties:
              type:
                type: string
              index:
                type: integer
                minimum: 0
              parameters:
                type: array
                items:
                  type: string
    TemplateSendResult:
      type: object
      title: Template send result
      properties:
        success:
          type: boolean
        wamid:
          type: string
          description: WhatsApp's message id.
        message_id:
          type: string
          format: uuid
          description: The persisted oToK inbox record for the sent message.
        conversation_id:
          type: string
          format: uuid
          description: The persisted oToK inbox conversation.

    # ── Pipelines & deals ──
    Pipeline:
      type: object
      title: Pipeline
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: [string, "null"]
        is_default:
          type: boolean
          description: "Exactly one pipeline per workspace has `is_default: true`."
        position:
          type: integer
        created_by:
          type: [string, "null"]
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        stages:
          type: array
          items:
            $ref: "#/components/schemas/PipelineStage"
    PipelineStage:
      type: object
      title: Pipeline stage
      properties:
        id:
          type: string
          format: uuid
        pipeline_id:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: [string, "null"]
        position:
          type: integer
        win_probability:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
          description: Integer 0–100 used for forecasting; `null` behaves as 100%.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Deal:
      type: object
      title: Deal
      description: |
        `GET /v1/deals`, `GET /v1/deals/{id}`, `POST /v1/deals`, and
        `PATCH /v1/deals/{id}` additionally join the contact's identity
        (`contact_name`/`contact_phone`/`contact_email`); the `/stage` and `/status`
        action routes return the bare deal row without them. `amount` serializes as a
        **JSON number** (e.g. `3600`) in responses — spec versions before 1.2.0
        incorrectly documented it as a decimal string; the wire has not changed.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        pipeline_id:
          type: string
          format: uuid
        stage_id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
        product_id:
          type: [string, "null"]
          format: uuid
        owner_user_id:
          type: [string, "null"]
          format: uuid
        title:
          type: string
          description: Derived from the product name while a product is attached.
        amount:
          type: [number, "null"]
          description: JSON number in responses, rounded to 2 decimals.
        currency:
          type: [string, "null"]
        status:
          type: string
          enum: [open, won, lost]
        lost_reason:
          type: [string, "null"]
        expected_close_at:
          type: [string, "null"]
          format: date-time
        closed_at:
          type: [string, "null"]
          format: date-time
        stage_entered_at:
          type: [string, "null"]
          format: date-time
        position:
          type: [number, "null"]
          description: Fractional Kanban position within the stage column.
        note:
          type: [string, "null"]
        external_reference:
          type: [string, "null"]
          description: Idempotency key, unique per workspace.
        source:
          type: [string, "null"]
          description: '`"api"` for API writes.'
        created_by:
          type: [string, "null"]
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        contact_name:
          type: [string, "null"]
          description: Joined contact identity — absent on `/stage` and `/status` responses.
        contact_phone:
          type: [string, "null"]
          description: Joined contact identity — absent on `/stage` and `/status` responses.
        contact_email:
          type: [string, "null"]
          description: Joined contact identity — absent on `/stage` and `/status` responses.
      additionalProperties: true
    DealUpsertResult:
      title: Deal upsert result
      description: |
        `POST /v1/deals` response — the deal (with joined contact identity fields) plus
        the top-level `duplicate` marker. Both outcomes return 201.
      allOf:
        - $ref: "#/components/schemas/Deal"
        - type: object
          required: [duplicate]
          properties:
            duplicate:
              type: boolean
              description: |
                `false` when this request created the deal; `true` when the
                `external_reference` matched an existing deal (fields updated / stage
                moved, status untouched).
    DealCreate:
      type: object
      title: Deal create/upsert payload
      description: |
        Provide `contact_id`, or `phone`/`email` (upserted like `POST /v1/contacts`).
        `title` is required unless a product is attached.
      additionalProperties: false
      properties:
        contact_id:
          type: string
          format: uuid
          description: Existing contact (404 if not in this workspace). Wins over `phone`/`email`.
        phone:
          type: string
          maxLength: 32
          description: International format preferred.
        email:
          type: string
          format: email
        name:
          type: string
          maxLength: 200
          description: Used only when a **new** contact is created.
        title:
          type: string
          maxLength: 200
          description: Required unless a product is attached (then derived from the product name).
        product_id:
          type: string
          format: uuid
          description: Attach a product by id.
        product_sku:
          type: string
          maxLength: 120
          description: Attach by SKU.
        product_external_id:
          type: string
          maxLength: 255
          description: Attach by your external product id.
        amount:
          type: number
          minimum: 0
          maximum: 9999999999
          description: |
            Rounded to 2 decimals. Omitted → product price if a product is attached,
            else 0.
        currency:
          type: string
          maxLength: 8
          description: Uppercased. Omitted → workspace default currency.
        pipeline_id:
          type: string
          format: uuid
          description: Omitted → the workspace default pipeline. Ignored on an `external_reference` match.
        stage_id:
          type: string
          format: uuid
          description: Omitted → the pipeline's first stage. On an `external_reference` match, a different stage moves the deal.
        owner_user_id:
          type: string
          format: uuid
          description: Must be a workspace agent. Omitted → the deal is unowned.
        expected_close_at:
          type: string
          format: date-time
        note:
          type: string
          maxLength: 4000
        external_reference:
          type: string
          maxLength: 255
          description: "**Idempotency key**, unique per workspace."
    DealPatch:
      type: object
      title: Deal patch payload
      description: All fields optional. Does not write stage history or fire deal automations.
      additionalProperties: false
      properties:
        product_id:
          type: [string, "null"]
          format: uuid
          description: |
            UUID attaches/replaces (must be active unless it's the already-attached
            product); `null` detaches — the title keeps its current text.
        title:
          type: string
          maxLength: 200
          description: Ignored while a product is (or ends up) attached.
        amount:
          type: number
          minimum: 0
          maximum: 9999999999
          description: Rounded to 2 decimals.
        currency:
          type: string
          maxLength: 8
          description: Uppercased.
        contact_id:
          type: string
          format: uuid
          description: Re-points the deal; 404 "Contact not found" if not in this workspace.
        owner_user_id:
          type: [string, "null"]
          format: uuid
          description: "`null` unassigns; a UUID must be a workspace agent (400 INVALID_DEAL_OWNER)."
        expected_close_at:
          type: [string, "null"]
          format: date-time
          description: "`null` clears."
        note:
          type: [string, "null"]
          maxLength: 4000
          description: Trimmed; empty becomes `null`.

    # ── Products ──
    Product:
      type: object
      title: Product
      description: |
        A row of the workspace product catalog, shared by deals and customer payments.
        `price` `null` = dynamic pricing (a deal referencing the product needs an
        explicit amount). `vat_mode`/`vat_rate` are one both-or-neither pair — `null`s
        mean the workspace payments default applies at resolution time. Inactive
        products stay attached to existing deals/payments but cannot be attached to new
        ones.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        name:
          type: string
        sku:
          type: [string, "null"]
          description: Per-workspace-unique product code (human-facing).
        external_id:
          type: [string, "null"]
          description: |
            Per-workspace-unique id of this product in your system — the POST
            idempotency key.
        description:
          type: [string, "null"]
        price:
          type: [number, "null"]
          description: |
            Default price in the workspace payment currency, as a JSON number. `null` =
            dynamic pricing.
        vat_mode:
          type: [string, "null"]
          enum: [inclusive, exclusive, null]
        vat_rate:
          type: [number, "null"]
          description: VAT percent (0–100); paired with `vat_mode`.
        is_active:
          type: boolean
        created_by:
          type: [string, "null"]
          format: uuid
          description: "`null` for API creates."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    ProductUpsertResult:
      title: Product upsert result
      description: |
        `POST /v1/products` response — the product plus the top-level `duplicate`
        marker. Both outcomes return 201.
      allOf:
        - $ref: "#/components/schemas/Product"
        - type: object
          required: [duplicate]
          properties:
            duplicate:
              type: boolean
              description: |
                `false` when this request created the product; `true` when the
                `external_id` matched an existing product, whose fields were updated
                instead.
    ProductWrite:
      type: object
      title: Product write payload
      description: |
        `POST /v1/products` (create/upsert — `name` required) and
        `PATCH /v1/products/{id}` (partial update — every field optional; only the
        fields present in the body change). `vat_mode` + `vat_rate` travel as one
        both-or-neither pair — send both `null` to clear the override (400 when only
        one leg is sent).
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        sku:
          type: [string, "null"]
          maxLength: 100
          description: Per-workspace-unique product code (human-facing).
        external_id:
          type: [string, "null"]
          maxLength: 200
          description: |
            Per-workspace-unique id of this product in your system — the POST
            idempotency key (a repeat POST with the same value updates that product
            instead of creating a duplicate).
        description:
          type: [string, "null"]
          maxLength: 2000
        price:
          type: [number, "null"]
          minimum: 0
          description: |
            Default price in the workspace payment currency. `null` = dynamic pricing
            (deals fall back to an explicit amount).
        vat_mode:
          type: [string, "null"]
          enum: [inclusive, exclusive, null]
          description: |
            Per-product VAT override. Travels with `vat_rate` as one both-or-neither
            pair; send both `null` to clear the override.
        vat_rate:
          type: [number, "null"]
          minimum: 0
          maximum: 100
          description: VAT percent (0–100, max 2 decimals); paired with `vat_mode`.
        is_active:
          type: boolean
          description: |
            Inactive products stay on existing deals/payments but cannot be attached
            to new ones.

    # ── Payments ──
    Payment:
      type: object
      title: Payment (header)
      description: |
        The payment header (the arrangement). Monetary values serialize as **JSON
        numbers** (e.g. `350`) in responses. List rows include joined
        `contact_name`/`contact_phone`/`contact_email` and do **not** include entries.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
        product_id:
          type: [string, "null"]
          format: uuid
        type:
          type: string
          enum: [one_time, recurring, installments]
        title:
          type: [string, "null"]
          description: Derived from the product name while a product is attached.
        note:
          type: [string, "null"]
        currency:
          type: [string, "null"]
        total_amount:
          type: [number, "null"]
          description:
            JSON number in responses, rounded to 2 decimals (incorrectly documented
            as a decimal string in spec versions before 1.2.0).
        arrangement_status:
          type: string
          enum: [active, completed, cancelled]
        method:
          type: [string, "null"]
          enum: [cash, card, bank_transfer, other, null]
        purchase_date:
          type: [string, "null"]
          format: date-time
        recurring_interval:
          type: [string, "null"]
          enum: [weekly, monthly, quarterly, yearly, null]
        recurring_next_due_at:
          type: [string, "null"]
          format: date-time
        recurring_auto_generate:
          type: [boolean, "null"]
        recurring_cancelled_at:
          type: [string, "null"]
          format: date-time
        recurring_end_at:
          type: [string, "null"]
          format: date-time
        recurring_max_occurrences:
          type: [integer, "null"]
        recurring_payment_method_id:
          type: [string, "null"]
          format: uuid
          description: |
            The saved card funding the plan's automatic charges, when one is attached.
        recurring_last_attempt_at:
          type: [string, "null"]
          format: date-time
          description: When the charging sweep last attempted this plan's due cycle.
        recurring_failure_count:
          type: integer
          description: Consecutive failed automatic charge attempts (reset on success).
        recurring_paused_at:
          type: [string, "null"]
          format: date-time
          description: Set when automatic charging is paused after the retry ladder ran out.
        recurring_next_retry_at:
          type: [string, "null"]
          format: date-time
          description: Earliest instant the next automatic retry of a failed cycle may run.
        recurring_dunning_started_at:
          type: [string, "null"]
          format: date-time
          description: First-failure anchor of the current automatic-charging retry episode.
        vat_mode:
          type: [string, "null"]
          enum: [inclusive, exclusive, null]
          description: |
            The recurring plan's stored VAT pair (with `vat_rate`) — `inclusive` (VAT
            included in the amount) or `exclusive` (the amount is net; VAT added on top;
            exclusive + rate 0 = VAT-exempt). Null on non-recurring payments and on plans
            created before VAT granularity — such plans resolve the live workspace
            default each cycle.
        vat_rate:
          type: [number, "null"]
          description: VAT rate percent (0–100, at most 2 decimals); null with `vat_mode`.
        installment_count:
          type: [integer, "null"]
        external_reference:
          type: [string, "null"]
          description: Idempotency key, unique per workspace.
        source:
          type: [string, "null"]
          description: '`"api"` for API writes.'
        metadata:
          type: [object, "null"]
          description: |
            Free-form JSON stored on the payment. May carry integration payloads and the
            standardized external tax-document pointer under `metadata.document`
            (`{ provider, id, number, type, url }`).
        created_by:
          type: [string, "null"]
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        contact_name:
          type: [string, "null"]
          description: Joined on list rows.
        contact_phone:
          type: [string, "null"]
          description: Joined on list rows.
        contact_email:
          type: [string, "null"]
          description: Joined on list rows.
      additionalProperties: true
    PaymentEntry:
      type: object
      title: Payment entry
      description: |
        An individual charge/refund in the payment's schedule. Refund entries carry
        **negative** `amount`/`recognized_amount` and point at the charge they reverse via
        `refunds_entry_id`. Monetary values serialize as JSON numbers in responses
        (incorrectly documented as decimal strings in spec versions before 1.2.0).
      properties:
        id:
          type: string
          format: uuid
        payment_id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
        sequence:
          type: integer
        amount:
          type: [number, "null"]
          description: JSON number; negative on refund entries.
        currency:
          type: [string, "null"]
        status:
          type: string
          enum: [pending, completed, failed, refunded]
        due_date:
          type: [string, "null"]
          format: date-time
        paid_at:
          type: [string, "null"]
          format: date-time
        recognized_amount:
          type: [number, "null"]
          description: JSON number; negative on refund entries.
        recognized_at:
          type: [string, "null"]
          format: date-time
        kind:
          type: string
          enum: [charge, refund]
        refunds_entry_id:
          type: [string, "null"]
          format: uuid
          description: On refund entries, the charge entry being reversed.
        note:
          type: [string, "null"]
        provider_refund_ref:
          type: [string, "null"]
          description: |
            Provider-side transaction reference of a gateway-executed refund (null for
            ledger-only refunds recorded via this API).
        refund_idempotency_key:
          type: [string, "null"]
          description: The idempotency key a provider-executed refund was requested with.
        credit_document:
          type: [object, "null"]
          description: |
            Credit document issued alongside a provider-executed refund, in the
            standardized `{ provider, id, number, type, url }` shape.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    PaymentWithEntries:
      title: Payment with entries
      description: |
        Single-payment responses (`GET /{id}` and all write routes) return the header plus
        `entries` ordered by `sequence`.
      allOf:
        - $ref: "#/components/schemas/Payment"
        - type: object
          required: [entries]
          properties:
            entries:
              type: array
              items:
                $ref: "#/components/schemas/PaymentEntry"
    PaymentUpsertResult:
      title: Payment upsert result
      description: |
        `POST /v1/payments` response — the payment with its entries plus the top-level
        `duplicate` marker. Both outcomes return 201.
      allOf:
        - $ref: "#/components/schemas/PaymentWithEntries"
        - type: object
          required: [duplicate]
          properties:
            duplicate:
              type: boolean
              description: |
                `false` when this request created the payment; `true` when the
                `external_reference` matched an existing payment (mutable fields updated).
    PaymentCreate:
      type: object
      title: Payment create/upsert payload
      description: Provide `contact_id`, or `phone`/`email` (upserted like `POST /v1/contacts`).
      additionalProperties: false
      required: [type, amount]
      properties:
        contact_id:
          type: string
          format: uuid
          description: Existing contact (404 if not in this workspace). Wins over `phone`/`email`.
        phone:
          type: string
          maxLength: 32
        email:
          type: string
          format: email
        name:
          type: string
          maxLength: 200
          description: Used only for a newly created contact.
        type:
          type: string
          enum: [one_time, recurring, installments]
        amount:
          type: number
          minimum: 0
          maximum: 9999999999
          description: |
            one_time: the charge amount; recurring: amount **per cycle**; installments:
            the **total** deal amount. Always required — the product price does not
            substitute for it.
        product_id:
          type: string
          format: uuid
        product_sku:
          type: string
          maxLength: 120
        product_external_id:
          type: string
          maxLength: 255
        title:
          type: string
          maxLength: 200
          description: Ignored while a product is attached (title derives from the product name).
        note:
          type: string
          maxLength: 1000
        method:
          type: string
          enum: [cash, card, bank_transfer, other]
        currency:
          type: string
          maxLength: 3
          description: Uppercased. Omitted → workspace default currency.
        purchase_date:
          type: string
          description: |
            ISO 8601. A date-only value ("2026-07-14") is interpreted as start of that day
            in the workspace timezone. Omitted → now.
        status:
          type: string
          enum: [pending, completed, failed, refunded]
          description: "**One-time only.** Omitted → `completed`."
        interval:
          type: string
          enum: [weekly, monthly, quarterly, yearly]
          description: "**Recurring only.** Omitted → `monthly`."
        auto_generate:
          type: boolean
          description: "Recurring only: automatically record each cycle when due."
        record_first_payment:
          type: boolean
          default: true
          description: "Recurring only: record the first cycle immediately."
        recurring_end_at:
          type: string
          description: |
            Recurring only; ISO 8601 — a date-only value means **end** of that day in the
            workspace timezone; must be after the purchase date.
        recurring_max_occurrences:
          type: integer
          minimum: 1
          description: Recurring only — total charge cycles ever.
        vat_mode:
          type: string
          enum: [inclusive, exclusive]
          description: |
            **Recurring plans only, always together with `vat_rate`** (a full pair —
            on other types the pair returns 400 `"vatMode/vatRate apply to recurring
            plans only"`; a lone leg returns 400). The VAT posture the plan's cycles
            are charged and documented with: `inclusive` (VAT included in the amount) or
            `exclusive` (the amount is net; VAT is added on top; exclusive + rate 0 =
            VAT-exempt). Omitted → the plan stores the attached product's VAT pair, else
            the workspace default. On an `external_reference` match a provided pair
            **re-prices** the plan.
        vat_rate:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Recurring only, always together with `vat_mode`: VAT rate percent (0–100, at
            most 2 decimal places).
        installment_count:
          type: integer
          minimum: 2
          maximum: 360
          description: |
            **Required when `type` is `installments`.** Over the ceiling → 400
            `"installmentCount must be at most 360"`.
        external_reference:
          type: string
          maxLength: 255
          description: "**Idempotency key**, unique per workspace."
        metadata:
          type: object
          description: |
            Free-form JSON stored on the payment — **max 2048 bytes serialized** (400
            `"metadata exceeds 2048 bytes serialized"` over the cap). Returned on reads
            and surfaced to payment automations. On an `external_reference` match the
            provided object **replaces** the stored one (omit to keep it).
    PaymentPatch:
      type: object
      title: Payment patch payload
      description: All fields optional. Type-restricted fields are silently ignored on other types.
      additionalProperties: false
      properties:
        product_id:
          type: [string, "null"]
          format: uuid
          description: |
            Attach/replace (must be active unless already attached) or detach (`null`);
            title derives from the product while attached.
        title:
          type: string
          maxLength: 200
          description: Ignored while a product is attached.
        note:
          type: string
          maxLength: 1000
        method:
          type: string
          enum: [cash, card, bank_transfer, other]
        amount:
          type: number
          minimum: 0
          maximum: 9999999999
          description: "**One-time only** (silently ignored otherwise)."
        status:
          type: string
          enum: [pending, completed, failed, refunded]
          description: "**One-time only.**"
        auto_generate:
          type: boolean
          description: Recurring only.
        recurring_end_at:
          type: [string, "null"]
          description: Recurring only; `null` clears; must be after the purchase date.
        recurring_max_occurrences:
          type: [integer, "null"]
          minimum: 1
          description: Recurring only; `null` clears.
        vat_mode:
          type: string
          enum: [inclusive, exclusive]
          description: |
            **Recurring only: replace the plan's stored VAT pair — always together with
            `vat_rate`.** Unlike the other type-restricted patch fields this is NOT
            silently ignored on other types (400 `"vatMode/vatRate apply to recurring
            plans only"`), and lone legs / `null`s are rejected (400 `"vatMode and
            vatRate must be provided together"`) — the stored pair is a complete value.
            Future cycles are charged and documented with the new pair. Omit both to
            leave the pair as-is.
        vat_rate:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Recurring only, always together with `vat_mode`: VAT rate percent (0–100, at
            most 2 decimal places).
        metadata:
          type: [object, "null"]
          description: |
            Replace the payment's free-form metadata object (max 2048 bytes serialized),
            or `null` to clear it. Omit to leave it as-is.

    # ── Payment requests ──
    PaymentRequest:
      type: object
      title: Payment request (pay-link)
      description: |
        A pay-by-link request collected through the workspace's own connected payment
        provider. Monetary values serialize as JSON numbers. Responses return the full
        stored row (additional provider-correlation fields may appear) plus computed
        fields: `pay_url` (the shareable hosted-checkout link) on create/get/list — the
        cancel response is the bare row without computed fields — and `document` (the
        issued tax-document pointer, once paid) on get/list. List rows additionally
        include joined `contact_name`/`contact_phone`/`contact_email` and a computed
        `refunded_total`.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        contact_id:
          type: [string, "null"]
          format: uuid
          description: The payer contact. Null only after the contact was deleted.
        deal_id:
          type: [string, "null"]
          format: uuid
          description: The deal the request is bound to, when minted for a deal.
        provider:
          type: string
          enum: [cardcom, sumit]
          description: The connected provider the money moves through.
        status:
          type: string
          enum: [pending, paid, expired, cancelled]
          description: "Lifecycle: pending → paid | expired | cancelled."
        charge_kind:
          type: string
          enum: [checkout, token]
          description: |
            `checkout` = a hosted pay-link (the rows this API mints). `token` rows are
            direct saved-card charge anchors created by the system — they appear in
            list/get, carry `pay_url: null`, cannot be cancelled
            (409 `TOKEN_REQUEST_NOT_CANCELLABLE`), and never emit `payment_request.*`
            webhook events.
        amount:
          type: number
          description: Amount to collect, in major units (JSON number).
        currency:
          type: string
          enum: [ILS, USD, EUR, GBP]
        title:
          type: [string, "null"]
          description: Payer-facing charge title.
        note:
          type: [string, "null"]
        max_installments:
          type: [integer, "null"]
          description: Max card installments offered on the hosted page.
        auto_issue_document:
          type: boolean
          description: Auto-issue an Israeli tax document on successful charge.
        document_kind:
          type: [string, "null"]
          description: |
            Tax-document kind to auto-issue; null = the provider/account default. See
            `PaymentRequestCreate.document_kind` for the vocabulary.
        public_token:
          type: string
          description: |
            High-entropy public pay-link token (`pr_…`) — the last path segment of
            `pay_url`. Treat it as a secret of the payer.
        provider_checkout_ref:
          type: [string, "null"]
          description: Provider-side checkout-session reference, when minted.
        provider_payment_ref:
          type: [string, "null"]
          description: Provider-side payment/transaction reference, once paid.
        provider_customer_ref:
          type: [string, "null"]
          description: Provider-side customer reference, when known.
        test_mode:
          type: boolean
          description: |
            Authorise-only test run — test requests never record real money and expire
            within 1 hour.
        vat_mode:
          type: [string, "null"]
          enum: [inclusive, exclusive, null]
          description: |
            The resolved VAT posture stamped at mint (request override → workspace
            default): the checkout, the auto-issued document, and any later refund
            credit-document all price with this pair. Null on rows minted before VAT
            granularity.
        vat_rate:
          type: [number, "null"]
          description: VAT rate percent (0–100); null with `vat_mode`.
        reminders_enabled:
          type: boolean
          description: Pre-expiry reminder emails for this request.
        reminder_count:
          type: integer
          description: Reminder tiers already sent.
        last_reminder_at:
          type: [string, "null"]
          format: date-time
        expires_at:
          type: [string, "null"]
          format: date-time
          description: Link deadline (clamped at mint; ≤72h, 1h in test mode).
        paid_at:
          type: [string, "null"]
          format: date-time
        cancelled_at:
          type: [string, "null"]
          format: date-time
        contact_payment_id:
          type: [string, "null"]
          format: uuid
          description: |
            The `/v1/payments` ledger row a verified payment landed on — set once paid.
        metadata:
          type: [object, "null"]
          description: |
            System-managed row metadata (e.g. the issued `document` pointer, checkout
            diagnostics). Not writable via this API.
        created_by:
          type: [string, "null"]
          format: uuid
          description: Null for API mints.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        pay_url:
          type: [string, "null"]
          description: |
            The shareable hosted pay-page URL
            (`https://app.otok.io/pay/<public_token>`). Computed on create/get/list —
            absent on the cancel response — and null on `charge_kind: "token"` rows,
            which have no payer-facing page.
        document:
          type: [object, "null"]
          description: |
            The issued tax-document pointer (`{ provider, id, number, type, url }`) once
            the request is paid — computed on get/list; null while unpaid.
        contact_name:
          type: [string, "null"]
          description: Joined on list rows.
        contact_phone:
          type: [string, "null"]
          description: Joined on list rows.
        contact_email:
          type: [string, "null"]
          description: Joined on list rows.
        refunded_total:
          type: number
          description: |
            List rows only — total already refunded against this request's settled
            charge (0 while unrefunded).
      additionalProperties: true
    PaymentRequestCreateResult:
      title: Payment request create result
      description: |
        `POST /v1/payment-requests` response — the minted row plus checkout diagnostics.
      allOf:
        - $ref: "#/components/schemas/PaymentRequest"
        - type: object
          properties:
            checkout_url:
              type: [string, "null"]
              description: |
                The provider checkout-session URL minted for the link, when the
                provider answered at mint time. The payer-facing URL to share remains
                `pay_url` — the hosted page (re)creates the provider session as needed.
            checkout_error:
              type: [string, "null"]
              description: |
                Set when the provider checkout mint failed — the row stays `pending`
                and the pay page retries the session when the link is opened; null on
                success.
    PaymentRequestCreate:
      type: object
      title: Payment request create payload
      description: |
        Provide the payer as `contact_id`, OR `phone`/`email` (upserted like
        `POST /v1/contacts`), OR a `deal_id` alone (the deal's contact pays). **There is
        no idempotency key on this resource — a repeat POST mints a second, independently
        payable link.**
      additionalProperties: false
      required: [amount]
      properties:
        contact_id:
          type: string
          format: uuid
          description: Existing contact (the payer). Wins over `phone`/`email`.
        phone:
          type: string
          maxLength: 32
          description: Contact phone (E.164). A matching contact is used, or created.
        email:
          type: string
          format: email
          description: Contact email. A matching contact is used, or created.
        name:
          type: string
          maxLength: 200
          description: Used only when a new contact is created.
        deal_id:
          type: string
          format: uuid
          description: |
            Deal to bind the request to. When no contact is given, the deal's contact is
            the payer.
        amount:
          type: number
          minimum: 0.01
          maximum: 9999999999.99
          description: Amount to collect, in major units (at most 2 decimal places).
        currency:
          type: string
          enum: [ILS, USD, EUR, GBP]
          description: Omitted → the workspace payment currency.
        title:
          type: string
          maxLength: 200
          description: Payer-facing charge title.
        note:
          type: string
          maxLength: 2000
        max_installments:
          type: integer
          minimum: 1
          maximum: 36
          description: Max card installments offered on the hosted page (1–36).
        document_kind:
          type: string
          enum:
            - tax_invoice
            - tax_invoice_receipt
            - receipt
            - receipt_for_invoice
            - proforma_invoice
            - donation_receipt
            - credit_invoice
            - credit_invoice_receipt
            - credit_receipt
            - credit_donation_receipt
            - order
            - price_quote
            - delivery_note
            - payment_demand
          description: |
            Tax-document kind to auto-issue on payment; omitted → the provider/account
            default.
        auto_issue_document:
          type: boolean
          default: true
          description: Auto-issue an Israeli tax document on successful charge.
        expires_at:
          type: string
          format: date-time
          description: |
            Link expiry (ISO 8601). Clamped server-side to at most 72 hours from now
            (1 hour for test-mode requests); omitted → the maximum.
        test_mode:
          type: boolean
          description: |
            Authorise-only test run — rejected (400) when the connected provider has no
            test mode. Test requests never record real money.
        reminders_enabled:
          type: boolean
          description: Pre-expiry reminder emails for this request; omitted → the workspace default.
        offer_card_save:
          type: boolean
          description: |
            Offer the payer a save-my-card checkbox on the pay page — honored only when
            the connected provider supports card capture at checkout.
        vat_mode:
          type: string
          enum: [inclusive, exclusive]
          description: |
            Per-request VAT override — mode and rate travel as a PAIR (each required
            when the other is present; a lone leg returns 400). Omitted → the workspace
            payments default. VAT-exempt = `exclusive` + rate 0.
        vat_rate:
          type: number
          minimum: 0
          maximum: 100
          description: |
            VAT rate percent (0–100, at most 2 decimal places); see `vat_mode` — the
            pair travels together.

    # ── Contact documents ──
    ContactDocument:
      type: object
      title: Contact financial document
      description: |
        One aggregated financial document (invoice / receipt / credit document) of a
        contact.
      required:
        - key
        - kind
        - rawType
        - isCredit
        - provider
        - documentId
        - number
        - url
        - date
        - amount
        - currency
        - origin
        - sources
      properties:
        key:
          type: string
          description: Aggregator-computed stable render key; carries no semantics.
        kind:
          type: [string, "null"]
          enum:
            - tax_invoice
            - tax_invoice_receipt
            - receipt
            - receipt_for_invoice
            - proforma_invoice
            - donation_receipt
            - credit_invoice
            - credit_invoice_receipt
            - credit_receipt
            - credit_donation_receipt
            - order
            - price_quote
            - delivery_note
            - payment_demand
            - null
          description: Canonical document kind when resolvable; else null with `rawType` set.
        rawType:
          type: [string, "null"]
          description: Original provider/free-text type label when `kind` is null.
        isCredit:
          type: boolean
          description: Credit/refund document.
        provider:
          type: [string, "null"]
          enum: [cardcom, sumit, null]
        documentId:
          type: [string, "null"]
          description: Provider durable document id, when the provider exposes one.
        number:
          type: [string, "null"]
          description: Human-facing document number.
        url:
          type: [string, "null"]
          description: |
            **May be null** (legacy number-only rows) — always check before opening, and
            only open http(s) URLs.
        date:
          type: [string, "null"]
          format: date-time
          description: |
            ISO 8601 UTC. Stored documents: the host-row instant; live documents: the
            provider document date.
        amount:
          type: [number, "null"]
          description: |
            Stored documents: the host-row amount; live documents: the document's own
            value.
        currency:
          type: [string, "null"]
        origin:
          type: string
          enum: [stored, live, merged]
          description: |
            Where the document came from — a stored pointer, the live provider listing,
            or both (merged).
        sources:
          type: array
          description: |
            The records this document was aggregated from. Each item is one of
            `{type: "contact_payment", id}`, `{type: "payment_entry", id, paymentId}`,
            `{type: "payment_request", id}`, or `{type: "provider", provider}`.
          items:
            type: object
            required: [type]
            properties:
              type:
                type: string
                enum: [contact_payment, payment_entry, payment_request, provider]
              id:
                type: string
                format: uuid
              paymentId:
                type: string
                format: uuid
                description: '`payment_entry` sources only — the entry''s parent payment.'
              provider:
                type: string
                description: '`provider` sources only.'
            additionalProperties: false
      additionalProperties: false
    ContactDocumentsResult:
      type: object
      title: Contact documents listing
      description: |
        `GET /v1/contacts/{id}/documents` response. `documents` is sorted date-descending
        (nulls last; tiebreak number descending, numeric-aware).
      required: [documents, live]
      properties:
        documents:
          type: array
          items:
            $ref: "#/components/schemas/ContactDocument"
        live:
          type: object
          description: Status of the opt-in live provider lookup.
          required: [attempted, ok, complete, error]
          properties:
            attempted:
              type: boolean
              description: |
                A live provider lookup was attempted (`?live=true` with a live-capable
                deployment; `false` on stored-only reads).
            ok:
              type: boolean
              description: '`false` = the provider lookup failed or timed out.'
            complete:
              type: boolean
              description: '`false` = the live listing may be missing documents (partial).'
            error:
              type: [string, "null"]
              enum: [timeout, provider_error, null]

    # ── Orders ──
    Order:
      type: object
      title: Order (header)
      description: |
        The order header. Money fields (`total`, `subtotal`, `discount_total`,
        `shipping_total`, `tax_total`, `refunded_total`) serialize as **JSON numbers**,
        rounded to 2 decimals in the order's charge currency — the same serialization
        deals and payments use. The joined contact identity
        (`contact_name`/`contact_phone`/`contact_email`) is included on list rows
        **and** detail/write responses. List rows omit `items`/`refunds`.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
          description: The order's contact — required, always set.
        order_number:
          type: integer
          description: Internal per-workspace sequential number, assigned at create.
        number:
          type: [string, "null"]
          description: |
            Store-side display number (e.g. `"#1001"`) — `null` for API/manual orders.
        platform:
          type: string
          description: |
            Origin: `"api"` for API-created orders, `"manual"` for in-app,
            `"automation"` for automation-created; orders synced from a connected store
            carry the store platform name.
        source:
          type: string
          description: Same vocabulary as `platform` for non-store orders.
        store_connection_id:
          type: [string, "null"]
          format: uuid
          description: |
            Store-sync provenance — populated for orders synced from a connected store;
            `null` for API/manual orders.
        store_domain:
          type: [string, "null"]
          description: Store-sync provenance — `null` for API/manual orders.
        external_order_id:
          type: [string, "null"]
          description: Store-side order id — `null` for API/manual orders.
        external_reference:
          type: [string, "null"]
          description: Idempotency key, unique per workspace.
        deal_id:
          type: [string, "null"]
          format: uuid
          description: Optional link to a deal of the same contact.
        financial_status:
          type: string
          enum: [pending, paid, partially_paid, refunded, partially_refunded, voided]
          description: |
            `partially_paid` appears on reads (set in-app) but is not reachable through
            `/v1` writes; `refunded`/`partially_refunded` are set only by recording
            refunds. Cancellation is a separate `cancelled_at` stamp, not a financial
            status.
        fulfillment_status:
          type: string
          enum: [unfulfilled, partially_fulfilled, fulfilled]
          description: |
            **Read-only via the API** — recorded in-app or by store sync; no `/v1` route
            sets it.
        currency:
          type: string
          description: 3-letter uppercase; defaults to the workspace currency.
        total:
          type: number
          description: |
            max(0, `subtotal` − `discount_total` + `shipping_total` + `tax_total`),
            2-decimal half-up.
        subtotal:
          type: number
          description: Sum of rounded line totals.
        discount_total:
          type: number
          description: Document-level discount.
        shipping_total:
          type: number
        tax_total:
          type: number
        refunded_total:
          type: number
          description: Rollup of the refund ledger.
        item_count:
          type: integer
          description: Quantity sum rounded to the nearest integer.
        first_item_name:
          type: [string, "null"]
        coupon_codes:
          type: array
          items:
            type: string
          description: Applied discount/coupon codes.
        placed_at:
          type: string
          format: date-time
          description: Order time (defaults to creation time).
        paid_at:
          type: [string, "null"]
          format: date-time
          description: |
            Stamped on first entry into a paid state; historical — kept through later
            refund states.
        cancelled_at:
          type: [string, "null"]
          format: date-time
          description: The cancellation stamp — cancellation is not a financial status.
        refunded_at:
          type: [string, "null"]
          format: date-time
          description: Last refund instant.
        external_updated_at:
          type: [string, "null"]
          format: date-time
          description: Store-sync snapshot clock — `null` for API/manual orders.
        payment_reference:
          type: [string, "null"]
          description: |
            Reference of the recorded payment backing this order (see the mark-paid
            endpoint).
        payment_synced_at:
          type: [string, "null"]
          format: date-time
          description: |
            Informational — when the recorded payment backing this order was last
            synced.
        note:
          type: [string, "null"]
        metadata:
          type: [object, "null"]
          description: Not settable via the API (in-app/store provenance).
        created_by:
          type: [string, "null"]
          format: uuid
          description: "`null` for API writes."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        contact_name:
          type: [string, "null"]
          description: Joined on list rows and detail/write responses.
        contact_phone:
          type: [string, "null"]
          description: Joined on list rows and detail/write responses.
        contact_email:
          type: [string, "null"]
          description: Joined on list rows and detail/write responses.
      additionalProperties: true
    OrderItem:
      type: object
      title: Order line item
      description: |
        Included on detail/write responses only, ordered by `position`. `line_total` is
        server-computed: round2(`quantity` × `unit_price` × (1 −
        `discount_percent`/100)).
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        order_id:
          type: string
          format: uuid
        position:
          type: integer
          description: 0-based.
        product_id:
          type: [string, "null"]
          format: uuid
          description: |
            Soft catalog link — `null` when no product resolved; survives product
            deletion as `null`.
        external_product_id:
          type: [string, "null"]
          description: Store-side product id — `null` for API-created lines.
        title:
          type: string
          description: Denormalized snapshot.
        sku:
          type: [string, "null"]
          description: Denormalized snapshot.
        quantity:
          type: number
          description: Decimal quantities allowed (weight/hours).
        unit_price:
          type: number
          description: In the order currency.
        discount_percent:
          type: [number, "null"]
          minimum: 0
          maximum: 100
          description: Percent-only per-line discount.
        line_total:
          type: number
        created_at:
          type: string
          format: date-time
      additionalProperties: true
    OrderRefund:
      type: object
      title: Order refund
      description: |
        A row in the order's append-only refund ledger. Included on detail/write
        responses only, ordered by `refunded_at` ascending.
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        order_id:
          type: string
          format: uuid
        external_refund_id:
          type: [string, "null"]
          description: Caller idempotency key; `null` for keyless refunds.
        amount:
          type: number
          description: Positive, in the order currency.
        currency:
          type: string
          description: The order's currency.
        reason:
          type: [string, "null"]
        refunded_at:
          type: string
          format: date-time
          description: Defaults to record time.
        created_at:
          type: string
          format: date-time
      additionalProperties: true
    OrderDetail:
      title: Order with items & refunds
      description: |
        Single-order responses (`GET /{id}` and all write routes) return the header plus
        `items` (ordered by `position`) and `refunds` (ordered by `refunded_at`
        ascending).
      allOf:
        - $ref: "#/components/schemas/Order"
        - type: object
          required: [items, refunds]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/OrderItem"
            refunds:
              type: array
              items:
                $ref: "#/components/schemas/OrderRefund"
    OrderRefundResult:
      type: object
      title: Order refund result
      description: |
        `POST /v1/orders/{id}/refunds` response — the `duplicate` marker plus the full
        current order (items + refunds).
      required: [duplicate, order]
      properties:
        duplicate:
          type: boolean
          description: |
            `true` when the `external_refund_id` was already recorded on this order —
            nothing was applied and the current order state is returned.
        order:
          $ref: "#/components/schemas/OrderDetail"
    OrderCreate:
      type: object
      title: Order create/upsert payload
      description: |
        Provide `contact_id`, or `phone`/`email` (upserted like `POST /v1/contacts`).
        When `external_reference` matches an existing order the request updates it
        instead — see the endpoint description for which fields apply on a match.
      additionalProperties: false
      properties:
        contact_id:
          type: string
          format: uuid
          description: Existing contact (404 if not in this workspace). Wins over `phone`/`email`.
        phone:
          type: string
          maxLength: 32
          description: International format preferred.
        email:
          type: string
          format: email
        name:
          type: string
          maxLength: 200
          description: Used only when a **new** contact is created.
        items:
          type: array
          maxItems: 200
          items:
            $ref: "#/components/schemas/OrderItemCreate"
        currency:
          type: string
          maxLength: 8
          description: Uppercased. Omitted → workspace default currency.
        discount_total:
          type: number
          minimum: 0
          maximum: 9999999999
          description: Document-level discount.
        shipping_total:
          type: number
          minimum: 0
          maximum: 9999999999
        tax_total:
          type: number
          minimum: 0
          maximum: 9999999999
        financial_status:
          type: string
          enum: [pending, paid]
          default: pending
          description: |
            A `paid` create records the payment and fires order-paid automations. Never
            applied on an `external_reference` match.
        placed_at:
          type: string
          format: date-time
          description: ISO 8601; defaults to now.
        coupon_codes:
          type: array
          maxItems: 50
          items:
            type: string
        note:
          type: string
          maxLength: 5000
        deal_id:
          type: string
          format: uuid
          description: |
            Must be a deal of the same contact — 404 `ORDER_DEAL_NOT_FOUND` when unknown
            to the workspace, 409 `ORDER_DEAL_CONTACT_MISMATCH` when it belongs to
            another contact.
        external_reference:
          type: string
          maxLength: 255
          description: "**Idempotency key**, unique per workspace."
    OrderItemCreate:
      type: object
      title: Order item payload
      description: |
        A product reference is optional; resolution order is `product_id` →
        `product_sku` → `product_external_id` (first provided wins). `product_id` is
        **strict** — unresolvable → 400 `INVALID_PRODUCT`;
        `product_sku`/`product_external_id` are **tolerant** — no match keeps the
        literal `title` with `product_id: null`. An **inactive** match always rejects
        (400 `PRODUCT_INACTIVE`). When a product resolves, the line title derives from
        the product name (client `title` ignored).
      additionalProperties: false
      properties:
        product_id:
          type: string
          format: uuid
          description: Explicit catalog product (strict).
        product_sku:
          type: string
          maxLength: 120
          description: Resolve by SKU (tolerant).
        product_external_id:
          type: string
          maxLength: 255
          description: Resolve by your external product id (tolerant).
        title:
          type: string
          maxLength: 300
          description: |
            Required unless a product resolves (`"Order items require a title or
            product_id"` otherwise); ignored when a product resolves.
        sku:
          type: string
          maxLength: 120
          description: Denormalized SKU snapshot on the line (falls back to `product_sku`).
        unit_price:
          type: number
          minimum: 0
          maximum: 9999999999
          description: |
            Omitted with a priced product → the product's price. Omitted with a
            dynamic-priced product (no catalog price) → 400
            `ORDER_ITEM_PRICE_REQUIRED`. Omitted with no product → 0.
        quantity:
          type: number
          exclusiveMinimum: 0
          default: 1
          description: Positive; decimals allowed.
        discount_percent:
          type: number
          minimum: 0
          maximum: 100
          description: Percent-only per-line discount.

    # ── Emails ──
    EmailSendRequest:
      type: object
      title: Transactional email send payload
      additionalProperties: false
      required: [to, subject, idempotency_key]
      properties:
        to:
          type: string
          minLength: 1
          maxLength: 320
          description: Must be a syntactically valid email (else 422 `invalid_recipient`).
        subject:
          type: string
          minLength: 1
          maxLength: 998
          description: No line breaks or control characters.
        html:
          type: string
          description: |
            ≤500 KB. Delivered verbatim. At least one of `html`/`text` is required; the
            missing part is derived from the other.
        text:
          type: string
          description: |
            ≤100 KB. At least one of `html`/`text` is required (text → HTML-escaped with
            line breaks preserved; html → a plain-text rendering).
        idempotency_key:
          type: string
          minLength: 1
          maxLength: 255
          description: Unique per workspace — makes retries safe.
        sender_profile_id:
          type: string
          format: uuid
          description: |
            Sender identity to use. Omitted → the workspace's default verified sender
            profile. An id from another workspace behaves like a nonexistent id (404).
        reply_to:
          type: string
          format: email
        headers:
          type: object
          description: |
            **Allowlist: only `List-Unsubscribe` and `List-Unsubscribe-Post`**
            (case-insensitive). Values: strings, ≤1000 chars, no control characters. Any
            other header key → 400 `validation_failed`.
          additionalProperties:
            type: string
            maxLength: 1000
        metadata:
          type: object
          description: |
            ≤2048 bytes JSON-serialized. Stored with the send and echoed verbatim in
            webhook events (`data.metadata`).
          additionalProperties: true
        tracking:
          type: object
          additionalProperties: false
          description: Both default `false`. Preferences are not persisted across replays.
          properties:
            opens:
              type: boolean
              default: false
            clicks:
              type: boolean
              default: false
    EmailSendResult:
      type: object
      title: Transactional email send result
      properties:
        id:
          type: string
          format: uuid
          description: The send's id — matches `data.send_id` in webhook events.
        status:
          type: string
          enum: [sent, suppressed]
        duplicate:
          type: boolean
          description: "`true` when this response replays a previously processed key."
        to:
          type: string
        idempotency_key:
          type: string
        provider_message_id:
          type: [string, "null"]
          description: Provider's message id; always `null` when suppressed.
        reason:
          type: [string, "null"]
          description: '`null`, or `"suppressed"` when the recipient was suppressed (deliberately coarse).'
        created_at:
          type: string
          format: date-time

    # ── Suppressions ──
    Suppression:
      type: object
      title: Suppression
      description: |
        A workspace email-suppression row — a **send-time overlay, deliberately
        separate from consent**: adding a row does not change any contact's consent
        state, and removing one resubscribes no one; every email send checks both.
        The API returns workspace-scoped rows only (the HQ-managed global list is
        enforced at send time but never listed).
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
        reason:
          type: string
          enum: [unsubscribe, bounce, complaint, manual, global]
          description: |
            Why the address is suppressed. API adds write one of
            `unsubscribe`/`bounce`/`complaint`/`manual`; system-created rows may carry
            other values.
        source:
          type: [string, "null"]
          description: 'Which surface created the row — `api` for API adds.'
        note:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
      additionalProperties: true
    SuppressionCreate:
      type: object
      title: Suppression create payload
      additionalProperties: false
      required: [email]
      properties:
        email:
          type: string
          format: email
          maxLength: 255
        reason:
          type: string
          enum: [unsubscribe, bounce, complaint, manual]
          description: Why the address is suppressed. Defaults to `manual`.
        note:
          type: string
          maxLength: 500
          description: Free-form note stored with the row.

    # ── Audiences & sender profiles ──
    AudienceSummary:
      type: object
      title: Audience (summary)
      description: |
        A saved workspace audience, as returned by `GET /v1/audiences`. The stored
        `definition` (the `$where` condition tree behind a dynamic audience) is
        **deliberately never exposed** through the public API — rows carry these
        summary columns only. `last_count` is an advisory size cache stamped when the
        audience was last counted or snapshotted in-app, never a live resolution —
        estimate a campaign's real reach with `GET /v1/email-campaigns/{id}/estimate`.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        kind:
          type: string
          enum: [dynamic, static]
          description: |
            `dynamic` — a stored condition tree re-evaluated live at every use;
            `static` — a frozen membership list.
        last_count:
          type: [integer, "null"]
          description: Advisory size cache — may be stale or `null` (never counted).
        last_counted_at:
          type: [string, "null"]
          format: date-time
          description: When `last_count` was stamped.
        created_at:
          type: [string, "null"]
          format: date-time
        updated_at:
          type: [string, "null"]
          format: date-time
      additionalProperties: true
    SenderProfile:
      type: object
      title: Sender profile
      description: |
        An email from-identity, as returned by `GET /v1/sender-profiles`. `from_email`
        is the composed sending address (the profile's local part @ its sending
        domain). `verified` is the send-readiness signal — `true` exactly when the
        linked sending domain's status is `verified`, the same gate in-app sends
        assert. DKIM/DNS verification material is never returned. Profiles are
        managed in-app (Settings → Email).
      properties:
        id:
          type: string
          format: uuid
        from_name:
          type: string
          description: Display name recipients see.
        from_email:
          type: string
          format: email
          description: The composed sending address (`local-part@domain`).
        reply_to:
          type: [string, "null"]
          description: Reply-to address, when it differs from `from_email`.
        provider:
          type: string
          enum: [smtp, ses]
          description: Email delivery backend behind this profile.
        is_default:
          type: boolean
          description: Whether this is the workspace's default sender profile.
        sending_domain_id:
          type: string
          format: uuid
        domain:
          type: string
          description: The sending domain (the part after `@` in `from_email`).
        domain_status:
          type: string
          enum: [pending, verifying, verified, failed, disabled]
          description: The sending domain's verification state.
        verified:
          type: boolean
          description: "`true` exactly when `domain_status` is `verified` — sends from this profile can pass the launch gate."
        created_at:
          type: [string, "null"]
          format: date-time
        updated_at:
          type: [string, "null"]
          format: date-time
      additionalProperties: true

    # ── Email campaigns & newsletters ──
    ContentInput:
      type: object
      title: Content input
      description: |
        The shared authoring contract for email-campaign bodies and newsletter issues: an
        optional `direction` plus **exactly one** of `markdown`, `blocks`, or
        `design_json` — zero or two-plus sources return 400 `invalid_content`. Whichever
        source you send is converted to the same internal email document the in-app
        editor produces and **compiled immediately**; the write response's `compile`
        envelope reports errors and warnings up front, not at send time.

        Total content size is capped at 512,000 characters (measured on the source and
        again on the built document) — an oversized body returns 400 `invalid_content`.

        **Variable tokens** work in all three sources: any `[[…]]` token in text becomes
        a personalization pill resolved per recipient at send time. The token grammar is
        `[[path]]`, `[[path : fallback]]`, or `[[path | modifier(arg) : fallback]]` —
        `path` is a contact field (`first_name`, `email`, custom field keys, or the
        explicit `contact.` prefix), the optional `fallback` renders when the value is
        empty, and optional modifiers format the value (e.g. `upper`, `date`). Example:
        `Hi [[first_name : there]]!`. Tokens also work inside the campaign `subject` and
        `preheader`.

        **Snippets** (reusable content blocks from the workspace library, managed in
        Settings → Snippets) can be spliced in by UUID or by case-insensitive exact
        name — via the `::snippet[…]` markdown directive or a `snippet` block. A
        reference that matches no workspace snippet returns 400 `unknown_snippet` with
        the available names listed in the message. The stored document keeps the
        reference, but the two surfaces resolve it at different moments: **email
        campaigns** re-expand snippet references at launch, so a snippet edited after
        the campaign was written still reaches the send; **newsletter issues** bake
        snippet content into the compiled document at save — re-save the issue (PATCH,
        or an `external_reference` create replay) to pick up later snippet edits.
      properties:
        direction:
          type: string
          enum: [ltr, rtl]
          default: ltr
          description: |
            Text direction applied to the produced blocks (`rtl` for
            right-to-left languages). Any other value returns 400 `invalid_content`.
        markdown:
          type: string
          description: |
            A CommonMark subset, converted block-by-block:

            - **Headings** `#`–`###` (deeper levels clamp to level 3 with a warning).
            - **Paragraphs** with `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``,
              and `[links](https://example.com)` (link/button URLs must be absolute
              http(s) — anything else is replaced with a safe placeholder).
            - **Bullet and numbered lists** (nesting supported), `> blockquotes`, and
              `---` horizontal dividers.
            - **Images** on their own line: `![alt](https://…)` — the URL must be
              absolute `https` (otherwise the image is dropped with a warning); an image
              inside a paragraph degrades to its alt text with a warning.
            - **Raw HTML is never passed through** — tags are stripped to their text
              content with a warning. Fenced code blocks degrade to a code-styled
              paragraph.

            Two **directive lines** (each on its own line) extend the grammar:

            - `::button[Label](https://url)` — a real call-to-action button block, themed
              with the workspace's primary color. A directive without a label is dropped
              with a warning.
            - `::snippet[name-or-uuid]` — splices a workspace snippet (see the schema
              description). Unknown references return 400 `unknown_snippet`.

            Any other `::directive` is kept as literal paragraph text with a warning.
          example: |
            # Big news, [[first_name : friend]]!

            We just shipped **three** features you asked for.

            ::button[See what's new](https://example.com/changelog)

            ::snippet[Footer]
        blocks:
          type: array
          description: |
            A typed block array for callers that prefer structure over text — each item
            is one block (see the Content block schema). Blocks render in order.
          items:
            $ref: "#/components/schemas/ContentBlock"
        design_json:
          type: object
          description: |
            A raw editor document (the native design JSON the in-app email editor
            submits), passed through as-is after a structural sanity check — it must be
            an object with a string `type` and an array `content`. Use this to replay a
            document exported from the app (e.g. a `GET` issue/campaign's `design_json`);
            authoring from scratch is easier with `markdown` or `blocks`.
      example:
        direction: ltr
        markdown: |
          # Hello [[first_name : there]]!

          Welcome to our July update.

          ::button[Read the full story](https://example.com/blog/july)
    ContentBlock:
      type: object
      title: Content block
      description: |
        One block of the `blocks` content source. `kind` selects the shape; the other
        fields apply per kind. An unknown `kind` or a mistyped field returns 400
        `invalid_content`. Text fields (`text`, `items` entries, button `label`) may
        embed `[[…]]` variable tokens.

        | kind | fields | notes |
        |---|---|---|
        | `heading` | `text`, `level` | `level` 1–3 (out of range clamps; omitted → 2). Empty text drops the block. |
        | `paragraph` | `text` | Empty text drops the block. |
        | `button` | `label`, `url` | Themed CTA button. A button without a label is dropped. Non-http(s) URLs are replaced with a safe placeholder. |
        | `bullets` | `items` | Bullet list; empty items are skipped. |
        | `spacer` | — | Fixed vertical spacing. |
        | `image` | `url`, `alt` | `url` must be absolute `https` — otherwise 400 `invalid_content`. |
        | `divider` | — | Horizontal rule. |
        | `snippet` | `id` **or** `name` | Splices a workspace snippet (id wins when both are sent). Neither → 400 `invalid_content`; no match → 400 `unknown_snippet`. |
      required: [kind]
      properties:
        kind:
          type: string
          enum: [heading, paragraph, button, bullets, spacer, image, divider, snippet]
        text:
          type: string
          description: "`heading` / `paragraph` text."
        level:
          type: integer
          minimum: 1
          maximum: 3
          description: "`heading` only."
        label:
          type: string
          description: "`button` only — the button text."
        url:
          type: string
          description: "`button` link / `image` source."
        items:
          type: array
          items:
            type: string
          description: "`bullets` only."
        alt:
          type: string
          description: "`image` only — alt text."
        id:
          type: string
          format: uuid
          description: "`snippet` only — resolve by snippet UUID."
        name:
          type: string
          description: "`snippet` only — resolve by case-insensitive exact name."
      example:
        kind: button
        label: Book a demo
        url: https://example.com/demo
    CompileResult:
      type: object
      title: Compile result
      description: |
        Present on every campaign/issue **write** response (create, PATCH, and
        idempotent replays that updated the record — never on plain GETs). The content
        compiles at write time through the same pipeline the send path uses, so problems
        surface here instead of at launch/publish. `errors` are render problems you
        should fix before sending; `warnings` are lossy-but-accepted conversions
        (stripped raw HTML, a dropped non-https image, clamped heading levels, an
        unknown `::directive` kept as text). `ok` is `true` when `errors` is empty. A
        write with no `content` in the payload reports `ok: true` with empty arrays.
      required: [ok, errors, warnings]
      properties:
        ok:
          type: boolean
        errors:
          type: array
          items:
            type: string
        warnings:
          type: array
          items:
            type: string
      example:
        ok: true
        errors: []
        warnings: ["Headings deeper than level 3 were clamped to level 3"]
    ScheduleAtRequest:
      type: object
      title: Schedule request
      required: [scheduled_at]
      properties:
        scheduled_at:
          type: string
          format: date-time
          description: |
            ISO 8601 UTC instant; must be in the future (otherwise 400
            `invalid_scheduled_at`). A non-ISO-8601 value fails request validation with
            the standard body.
          example: "2026-08-01T09:00:00Z"
    AudienceEstimate:
      type: object
      title: Audience estimate
      required: [estimated_recipients]
      properties:
        estimated_recipients:
          type: integer
          description: Recipients the stored targeting resolves to right now.
    EmailCampaign:
      type: object
      title: Email campaign
      description: |
        A broadcast email campaign. The stored subject/preheader override columns are
        read and written under the names `subject`/`preheader` — a GET → tweak → PATCH
        round-trip echoes cleanly. **List rows omit** `template_id`, `design_json`,
        `compiled_html`, `compiled_styles`, and `plain_text`; single reads include them.
        In-app-only fields (A/B configuration and results, warming state) are not
        documented here but may appear on single reads.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        status:
          type: string
          enum: [draft, scheduled, sending, paused, sent, failed, cancelled]
          description: |
            The API mutates only `draft`/`scheduled` campaigns; everything from the
            launch claim onward (`sending`, `paused`, `sent`, `failed`, `cancelled`) is
            system-managed.
        sender_profile_id:
          type: string
          format: uuid
        subject:
          type: [string, "null"]
          description: May embed `[[…]]` variable tokens, resolved per recipient.
        preheader:
          type: [string, "null"]
        direction:
          type: [string, "null"]
          enum: [rtl, ltr, null]
        topic_key:
          type: [string, "null"]
          description: Preference-center topic — opted-out contacts are excluded at send.
        contact_group_ids:
          type: [array, "null"]
          items:
            type: string
            format: uuid
        audience_id:
          type: [string, "null"]
          format: uuid
        audience_filters:
          type: [object, "null"]
          description: A `$where` condition tree (same grammar as the contacts filter `$where`).
        template_id:
          type: [string, "null"]
          format: uuid
          description: |
            In-app template reference. Always `null` for API-authored content; a PATCH
            with `content` clears it (the patched content is what sends). Omitted on
            list rows.
        design_json:
          type: [object, "null"]
          description: The stored editor document. Omitted on list rows.
        compiled_html:
          type: [string, "null"]
          description: Compiled body fragment. Omitted on list rows.
        compiled_styles:
          type: [string, "null"]
          description: Compiled head styles. Omitted on list rows.
        plain_text:
          type: [string, "null"]
          description: Compiled plain-text part. Omitted on list rows.
        scheduled_at:
          type: [string, "null"]
          format: date-time
        timezone:
          type: [string, "null"]
        started_at:
          type: [string, "null"]
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time
        total_recipients:
          type: [integer, "null"]
        sent_count:
          type: [integer, "null"]
        delivered_count:
          type: [integer, "null"]
        open_count:
          type: [integer, "null"]
        click_count:
          type: [integer, "null"]
        bounce_count:
          type: [integer, "null"]
        complaint_count:
          type: [integer, "null"]
        unsubscribe_count:
          type: [integer, "null"]
        failed_count:
          type: [integer, "null"]
        skipped_count:
          type: [integer, "null"]
          description: Terminal-but-not-failed recipients (suppression/eligibility skips).
        pending_retry_count:
          type: [integer, "null"]
        external_reference:
          type: [string, "null"]
          description: Your idempotency reference (unique per workspace).
        created_at:
          type: [string, "null"]
          format: date-time
        updated_at:
          type: [string, "null"]
          format: date-time
      additionalProperties: true
    EmailCampaignCreate:
      type: object
      title: Email campaign create payload
      description: Unknown properties are rejected with 400.
      additionalProperties: false
      required: [name, subject, sender_profile_id, content]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        subject:
          type: string
          minLength: 1
          maxLength: 400
          description: May embed `[[…]]` variable tokens.
          example: "Big news, [[first_name : there]]!"
        preheader:
          type: string
          maxLength: 400
          description: The inbox preview line rendered after the subject.
        sender_profile_id:
          type: string
          format: uuid
          description: |
            A sender profile in this workspace (unknown → 400
            `sender_profile_not_found`). Send-readiness — verified domain + legal footer
            fields — is asserted at launch, not here.
        external_reference:
          type: string
          maxLength: 255
          description: |
            Idempotency key: a repeat POST with the same value updates the matched
            campaign (while draft/scheduled) instead of creating a duplicate, and the
            response carries `duplicate: true`.
        content:
          $ref: "#/components/schemas/ContentInput"
        audience_id:
          type: string
          format: uuid
          description: A saved audience; wins over `audience_filters`. Must belong to your workspace (404 otherwise).
        audience_filters:
          type: object
          description: |
            An ad-hoc `$where` condition tree; validated structurally and test-compiled
            at write time (failures return 400 with an `errors` array). Ignored when
            `audience_id` is set.
        contact_group_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Additional contact-group targeting (OR semantics), narrowing the audience.
        topic_key:
          type: string
          maxLength: 200
          description: Preference-center topic key — contacts who opted out of it are excluded.
    EmailCampaignUpdate:
      type: object
      title: Email campaign update payload
      description: |
        Same field set as create minus `external_reference`, all optional — only present
        fields are touched. Nullable fields (`subject`, `preheader`, `audience_id`,
        `audience_filters`, `contact_group_ids`, `topic_key`) clear on an explicit
        `null`; `name`, `sender_profile_id`, and `content` do not accept `null`.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        subject:
          type: [string, "null"]
          minLength: 1
          maxLength: 400
          description: "`null` clears it; an empty string is rejected."
        preheader:
          type: [string, "null"]
          maxLength: 400
        sender_profile_id:
          type: string
          format: uuid
        content:
          $ref: "#/components/schemas/ContentInput"
        audience_id:
          type: [string, "null"]
          format: uuid
        audience_filters:
          type: [object, "null"]
        contact_group_ids:
          type: [array, "null"]
          items:
            type: string
            format: uuid
        topic_key:
          type: [string, "null"]
          maxLength: 200
    EmailCampaignWriteResult:
      title: Email campaign write result
      description: |
        A campaign write response: the campaign plus the `compile` envelope. `duplicate`
        appears only on `POST /v1/email-campaigns` (`false` on a fresh create, `true`
        when `external_reference` matched); on a **post-launch** verbatim replay the
        `compile` envelope is omitted (nothing was recompiled).
      allOf:
        - $ref: "#/components/schemas/EmailCampaign"
        - type: object
          properties:
            duplicate:
              type: boolean
              description: Present on POST only.
            compile:
              $ref: "#/components/schemas/CompileResult"
    Newsletter:
      type: object
      title: Newsletter
      description: |
        A smart newsletter (a sequence of issues with per-subscriber catch-up). List
        rows carry the summary fields documented here; `GET /v1/newsletters/{id}`
        additionally returns the full stored configuration — enrollment policy
        (`start_policy`, `start_issue_number`, `first_send_mode`,
        `remember_window_days`), catch-up cadence (`catchup_interval_days`,
        `catchup_send_time`), tag auto-subscribe, header/footer snippet assignments, and
        public-archive settings (`archive_enabled`, `slug`, `archive_mode`) — which are
        managed in-app and not exhaustively documented here.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: [string, "null"]
        status:
          type: string
          enum: [active, paused, archived]
        sender_profile_id:
          type: [string, "null"]
          format: uuid
          description: "`null` = the workspace default profile is used at send time."
        active_subscriber_count:
          type: integer
          description: Computed — subscriptions currently in `active` status.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    NewsletterCreate:
      type: object
      title: Newsletter create payload
      description: |
        A `name` alone suffices — everything else takes its in-app default. Unknown
        properties are rejected with 400.
      additionalProperties: false
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
          description: Unique per workspace, case-insensitive (409 `duplicate_name`).
        description:
          type: string
          maxLength: 2000
        sender_profile_id:
          type: string
          format: uuid
          description: |
            Omit to fall back to the workspace default profile at send time. Unknown →
            400 `sender_profile_not_found`.
    NewsletterIssue:
      type: object
      title: Newsletter issue
      description: |
        One issue in a newsletter's sequence. `issue_number` is assigned **at publish**
        (`null` on drafts/scheduled issues) — issue #N always means the Nth published
        issue. **List rows omit** `design_json`, `compiled_html`, `compiled_styles`, and
        `plain_text`; single reads include them.
      properties:
        id:
          type: string
          format: uuid
        newsletter_id:
          type: string
          format: uuid
        issue_number:
          type: [integer, "null"]
          description: Assigned at publish; `null` until then.
        subject:
          type: [string, "null"]
          description: May embed `[[…]]` variable tokens, resolved per recipient.
        preheader:
          type: [string, "null"]
        status:
          type: string
          enum: [draft, scheduled, published]
        design_json:
          type: [object, "null"]
          description: The stored editor document. Omitted on list rows.
        compiled_html:
          type: [string, "null"]
          description: Compiled body fragment. Omitted on list rows.
        compiled_styles:
          type: [string, "null"]
          description: Compiled head styles. Omitted on list rows.
        plain_text:
          type: [string, "null"]
          description: Compiled plain-text part. Omitted on list rows.
        scheduled_at:
          type: [string, "null"]
          format: date-time
        published_at:
          type: [string, "null"]
          format: date-time
        include_in_archive:
          type: boolean
          description: Whether the issue appears in the newsletter's public archive (when enabled).
        external_reference:
          type: [string, "null"]
          description: Your idempotency reference (unique per workspace).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    NewsletterIssueCreate:
      type: object
      title: Newsletter issue create payload
      description: |
        All fields optional — an issue can start as an empty placeholder, but publishing
        or scheduling requires a subject and compiled content. Unknown properties are
        rejected with 400.
      additionalProperties: false
      properties:
        subject:
          type: string
          maxLength: 400
          description: May embed `[[…]]` variable tokens.
        preheader:
          type: string
          maxLength: 400
        include_in_archive:
          type: boolean
          default: true
        external_reference:
          type: string
          maxLength: 255
          description: |
            Idempotency key: a repeat POST with the same value updates the matched
            issue's content/fields instead of creating a duplicate (`duplicate: true` in
            the response) — never its `status`, `scheduled_at`, or `issue_number`.
        content:
          $ref: "#/components/schemas/ContentInput"
    NewsletterIssueUpdate:
      type: object
      title: Newsletter issue update payload
      description: |
        Only present fields are touched. `subject`/`preheader` clear on an explicit
        `null`; `include_in_archive` and `content` do not accept `null`.
      additionalProperties: false
      properties:
        subject:
          type: [string, "null"]
          maxLength: 400
        preheader:
          type: [string, "null"]
          maxLength: 400
        include_in_archive:
          type: boolean
        content:
          $ref: "#/components/schemas/ContentInput"
    NewsletterIssueWriteResult:
      title: Newsletter issue write result
      description: |
        An issue write response: the full issue (content columns included) plus the
        `compile` envelope. `duplicate` appears only on
        `POST /v1/newsletters/{id}/issues` (`false` on a fresh create, `true` when
        `external_reference` matched an existing issue).
      allOf:
        - $ref: "#/components/schemas/NewsletterIssue"
        - type: object
          properties:
            duplicate:
              type: boolean
              description: Present on POST only.
            compile:
              $ref: "#/components/schemas/CompileResult"

    # ── Webhook endpoints ──
    WebhookEventType:
      type: string
      title: Webhook event type
      enum:
        - email.delivered
        - email.bounced
        - email.complained
        - email.failed
        - email.opened
        - email.clicked
        - order.created
        - order.paid
        - order.refunded
        - order.cancelled
        - order.fulfilled
        - payment_request.created
        - payment_request.paid
        - payment_request.expired
        - payment_request.cancelled
        - contact.created
        - contact.updated
        - contact.deleted
        - contact.consent_changed
        - message.received
        - deal.created
        - deal.stage_changed
        - deal.won
        - deal.lost
        - booking.created
        - booking.rescheduled
        - booking.cancelled
        - booking.reassigned
        - event.attendance.changed
        - form.submitted
      description: |
        The full registration vocabulary for webhook endpoints — the six email events
        (see `EmailEventType` for their semantics), the five `order.*` lifecycle
        events (see `OrderEventType`), the four `payment_request.*` pay-link
        lifecycle events (see `PaymentRequestEventType`), the four contact lifecycle +
        consent events (`contact.*`), the inbound message event (`message.received`),
        the four `deal.*` lifecycle events (see `DealEventType`), the four `booking.*`
        lifecycle events (see `BookingEventType`), the event-attendance event
        (`event.attendance.changed`), and the form submission event
        (`form.submitted`) — payloads under this spec's webhooks section.
        Every non-email family rides the same signing, retry schedule, and delivery
        behavior as the email events, and is received only when explicitly listed at
        registration — a pre-existing endpoint never starts receiving a new family
        unasked. Order and deal events fire for **every** write source (API, in-app,
        automations, integrations) — order events never fire for historical imports.
    EmailEventType:
      type: string
      title: Email event type
      enum:
        - email.delivered
        - email.bounced
        - email.complained
        - email.failed
        - email.opened
        - email.clicked
      description: |
        `email.delivered`/`email.bounced`/`email.complained` are the default subscription;
        `email.opened`/`email.clicked` are opt-in. `email.failed` is **deprecated** — still
        accepted at registration (echoed in `events`) but never delivered; nothing produces
        this event, and a failing `POST /v1/emails` send fails synchronously on the request
        itself.
    OrderEventType:
      type: string
      title: Order event type
      enum:
        - order.created
        - order.paid
        - order.refunded
        - order.cancelled
        - order.fulfilled
      description: |
        The five order lifecycle events. All are **opt-in**: delivered only to endpoints
        that list them explicitly in `events` — an endpoint registered without an
        `events` list receives none of them. They fire for **every** order write source
        (API, in-app, automations), never for historical import ingestion. Payload
        shape: `OrderEventBase` (plus the `refund` block on `OrderRefundedEvent`).
    PaymentRequestEventType:
      type: string
      title: Payment-request event type
      enum:
        - payment_request.created
        - payment_request.paid
        - payment_request.expired
        - payment_request.cancelled
      description: |
        The four pay-link lifecycle events. All are **opt-in**: delivered only to
        endpoints that list them explicitly in `events` — an endpoint registered
        without an `events` list receives none of them. **Hosted pay-links only**
        (`charge_kind: "checkout"`): direct saved-card charges and internal
        dunning-recovery links never emit these events. Payload shape:
        `PaymentRequestEventBase`.
    DealEventType:
      type: string
      title: Deal event type
      enum:
        - deal.created
        - deal.stage_changed
        - deal.won
        - deal.lost
      description: |
        The four deal lifecycle events. All are **opt-in**: delivered only to endpoints
        that list them explicitly in `events`. They fire for **every** deal write
        source — manual, API, automations, and Salesforce sync — `data.source` says
        which. Payload shape: `DealEventBase`.
    BookingEventType:
      type: string
      title: Booking event type
      enum:
        - booking.created
        - booking.rescheduled
        - booking.cancelled
        - booking.reassigned
      description: |
        The four booking lifecycle events. All are **opt-in**: delivered only to
        endpoints that list them explicitly in `events`. `booking.completed` /
        `booking.no_show` deliberately do not exist as webhooks — those statuses are
        sweep-derived (a cron inferring the past), not user actions. Payload shape:
        `BookingEventBase`.
    WebhookEndpoint:
      type: object
      title: Webhook endpoint
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
        events:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEventType"
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
    WebhookEndpointWithSecret:
      title: Webhook endpoint (with one-time secret)
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: |
                `whsec_…` signing secret — returned only on creation, once. It cannot be
                retrieved again.

    # ── Email webhook events ──
    EmailEventBase:
      type: object
      title: Email event (base)
      description: |
        Every delivery is an HTTP POST with this JSON body. Optional fields are **omitted,
        never null**. The retry policy is 10 attempts with exponential backoff (30s, 90s,
        4.5m, 13.5m, 40m, 2h, then 4h between later attempts — ≈16 hours total window);
        per-attempt timeout 10 seconds; redirects not followed; responses over 1 MB are
        failures; exhausted deliveries are not replayed.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it as
            your dedup key.
        type:
          $ref: "#/components/schemas/EmailEventType"
        created_at:
          type: string
          format: date-time
          description: When the event occurred.
        data:
          type: object
          additionalProperties: true
          required: [send_id, idempotency_key, to]
          properties:
            send_id:
              type: string
              format: uuid
              description: The `id` returned by `POST /v1/emails`.
            idempotency_key:
              type: string
              description: Your idempotency key from the send.
            to:
              type: string
              description: Recipient address.
            reason:
              type: string
              description: |
                Bounce diagnostic or complaint feedback type — omitted when absent.
            metadata:
              type: object
              additionalProperties: true
              description: Your `metadata` object, echoed verbatim — omitted when none.
    EmailDeliveredEvent:
      title: email.delivered event
      allOf:
        - $ref: "#/components/schemas/EmailEventBase"
        - type: object
          properties:
            type:
              const: email.delivered
    EmailBouncedEvent:
      title: email.bounced event
      allOf:
        - $ref: "#/components/schemas/EmailEventBase"
        - type: object
          properties:
            type:
              const: email.bounced
            data:
              type: object
              additionalProperties: true
              properties:
                bounce_type:
                  type: string
                  enum: [hard, soft, block]
                  description: Included when known; omitted when unknown.
    EmailComplainedEvent:
      title: email.complained event
      allOf:
        - $ref: "#/components/schemas/EmailEventBase"
        - type: object
          properties:
            type:
              const: email.complained
    EmailFailedEvent:
      title: email.failed event (deprecated)
      deprecated: true
      description: |
        Deprecated — never delivered; nothing produces this event. Subscriptions listing it
        explicitly are still accepted for back-compat, but no delivery will ever arrive.
      allOf:
        - $ref: "#/components/schemas/EmailEventBase"
        - type: object
          properties:
            type:
              const: email.failed
    EmailOpenedEvent:
      title: email.opened event
      allOf:
        - $ref: "#/components/schemas/EmailEventBase"
        - type: object
          properties:
            type:
              const: email.opened
            data:
              type: object
              additionalProperties: true
              required: [machine_open]
              properties:
                machine_open:
                  type: boolean
                  description: |
                    Always present on opens. `true` means the open was attributed to an
                    automated mail scanner / prefetcher rather than a human.
    EmailClickedEvent:
      title: email.clicked event
      allOf:
        - $ref: "#/components/schemas/EmailEventBase"
        - type: object
          properties:
            type:
              const: email.clicked
            data:
              type: object
              additionalProperties: true
              required: [url]
              properties:
                url:
                  type: string
                  description: |
                    Always present on clicks — the original destination URL (may be an
                    empty string in rare cases).

    # ── Order webhook events ──
    OrderEventBase:
      type: object
      title: Order event (base)
      description: |
        Every delivery is an HTTP POST with this JSON body — the same envelope
        (`id`/`type`/`created_at`/`data`), headers, signing, and retry policy as the
        email events (see `EmailEventBase`). Unlike email events, order event `data`
        always carries the **full field set** — absent values are explicit `null`s,
        never omitted keys. The `data` object is a snapshot of the order at event time.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it as
            your dedup key.
        type:
          $ref: "#/components/schemas/OrderEventType"
        created_at:
          type: string
          format: date-time
          description: When the event occurred.
        data:
          type: object
          additionalProperties: true
          required:
            - order_id
            - external_id
            - number
            - platform
            - store_connection_id
            - financial_status
            - fulfillment_status
            - currency
            - total
            - subtotal
            - discount_total
            - shipping_total
            - tax_total
            - refunded_total
            - coupon_codes
            - item_count
            - first_item_name
            - placed_at
            - paid_at
            - cancelled_at
            - refunded_at
            - created_at
          properties:
            order_id:
              type: string
              format: uuid
              description: The order's `id`.
            external_id:
              type: [string, "null"]
              description: Store-side order id — `null` for API- and app-created orders.
            number:
              type: string
              description: |
                Display number **as a string**: the store display number when present,
                else the internal sequential `order_number` rendered as a string.
            platform:
              type: string
              description: |
                Order origin — `api`, `manual`, or `automation` (store platform names
                reserved).
            store_connection_id:
              type: [string, "null"]
              format: uuid
              description: Store provenance — `null` for API- and app-created orders.
            financial_status:
              type: string
              enum: [pending, paid, partially_paid, refunded, partially_refunded, voided]
              description: Financial status at event time.
            fulfillment_status:
              type: string
              enum: [unfulfilled, partially_fulfilled, fulfilled]
              description: Fulfillment status at event time.
            currency:
              type: string
              description: 3-letter uppercase — the order's charge currency.
            total:
              type: number
              description: |
                JSON number, rounded to 2 decimals, in the order's charge currency
                (same for all money fields below).
            subtotal:
              type: number
            discount_total:
              type: number
            shipping_total:
              type: number
            tax_total:
              type: number
            refunded_total:
              type: number
            coupon_codes:
              type: array
              items:
                type: string
            item_count:
              type: integer
            first_item_name:
              type: [string, "null"]
            placed_at:
              type: string
              format: date-time
              description: ISO 8601 UTC (instants below are ISO 8601 UTC or `null`).
            paid_at:
              type: [string, "null"]
              format: date-time
            cancelled_at:
              type: [string, "null"]
              format: date-time
            refunded_at:
              type: [string, "null"]
              format: date-time
            created_at:
              type: string
              format: date-time
    OrderCreatedEvent:
      title: order.created event
      allOf:
        - $ref: "#/components/schemas/OrderEventBase"
        - type: object
          properties:
            type:
              const: order.created
    OrderPaidEvent:
      title: order.paid event
      allOf:
        - $ref: "#/components/schemas/OrderEventBase"
        - type: object
          properties:
            type:
              const: order.paid
    OrderRefundedEvent:
      title: order.refunded event
      allOf:
        - $ref: "#/components/schemas/OrderEventBase"
        - type: object
          properties:
            type:
              const: order.refunded
            data:
              type: object
              additionalProperties: true
              required: [refund]
              properties:
                refund:
                  type: object
                  description: The refund that fired this event.
                  required: [amount, external_refund_id, reason, refunded_at]
                  properties:
                    amount:
                      type: number
                      description: Positive, in the order's currency.
                    external_refund_id:
                      type: [string, "null"]
                      description: The caller's refund idempotency key — `null` for keyless refunds.
                    reason:
                      type: [string, "null"]
                    refunded_at:
                      type: string
                      format: date-time
    OrderCancelledEvent:
      title: order.cancelled event
      allOf:
        - $ref: "#/components/schemas/OrderEventBase"
        - type: object
          properties:
            type:
              const: order.cancelled
    OrderFulfilledEvent:
      title: order.fulfilled event
      allOf:
        - $ref: "#/components/schemas/OrderEventBase"
        - type: object
          properties:
            type:
              const: order.fulfilled

    # ── Payment-request webhook events ──
    PaymentRequestEventBase:
      type: object
      title: Payment-request event (base)
      description: |
        Every delivery is an HTTP POST with this JSON body — the same envelope
        (`id`/`type`/`created_at`/`data`), headers, signing, and retry policy as the
        email and order events (see `EmailEventBase`). Like order events, `data` always
        carries the **full field set** — absent values are explicit `null`s, never
        omitted keys. The `data` object is a snapshot of the payment request at event
        time. Money is a JSON number in the request's currency; instants are ISO-8601
        UTC or `null`. `test_mode: true` marks authorise-only test rows — their `paid`
        events never represent real money.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it as
            your dedup key.
        type:
          $ref: "#/components/schemas/PaymentRequestEventType"
        created_at:
          type: string
          format: date-time
          description: When the event occurred.
        data:
          type: object
          additionalProperties: true
          required:
            - payment_request_id
            - status
            - contact_id
            - deal_id
            - provider
            - amount
            - currency
            - title
            - vat_mode
            - vat_rate
            - test_mode
            - pay_url
            - contact_payment_id
            - expires_at
            - paid_at
            - cancelled_at
            - created_at
          properties:
            payment_request_id:
              type: string
              format: uuid
              description: The payment request's `id`.
            status:
              type: string
              enum: [pending, paid, expired, cancelled]
              description: Status at event time (`pending` on `payment_request.created`).
            contact_id:
              type: [string, "null"]
              format: uuid
            deal_id:
              type: [string, "null"]
              format: uuid
            provider:
              type: string
              description: '`cardcom` or `sumit`.'
            amount:
              type: number
              description: JSON number in the request's currency.
            currency:
              type: string
            title:
              type: [string, "null"]
            vat_mode:
              type: [string, "null"]
              enum: [inclusive, exclusive, null]
            vat_rate:
              type: [number, "null"]
            test_mode:
              type: boolean
              description: |
                Always present. `true` = authorise-only test request — never real money.
            pay_url:
              type: [string, "null"]
              description: The same hosted pay-link URL the API/app expose.
            contact_payment_id:
              type: [string, "null"]
              format: uuid
              description: The settled `/v1/payments` ledger row, linked once paid.
            expires_at:
              type: [string, "null"]
              format: date-time
              description: ISO 8601 UTC (instants below are ISO 8601 UTC or `null`).
            paid_at:
              type: [string, "null"]
              format: date-time
            cancelled_at:
              type: [string, "null"]
              format: date-time
            created_at:
              type: [string, "null"]
              format: date-time
    PaymentRequestCreatedEvent:
      title: payment_request.created event
      allOf:
        - $ref: "#/components/schemas/PaymentRequestEventBase"
        - type: object
          properties:
            type:
              const: payment_request.created
    PaymentRequestPaidEvent:
      title: payment_request.paid event
      allOf:
        - $ref: "#/components/schemas/PaymentRequestEventBase"
        - type: object
          properties:
            type:
              const: payment_request.paid
    PaymentRequestExpiredEvent:
      title: payment_request.expired event
      allOf:
        - $ref: "#/components/schemas/PaymentRequestEventBase"
        - type: object
          properties:
            type:
              const: payment_request.expired
    PaymentRequestCancelledEvent:
      title: payment_request.cancelled event
      allOf:
        - $ref: "#/components/schemas/PaymentRequestEventBase"
        - type: object
          properties:
            type:
              const: payment_request.cancelled

    # ── Contact webhook events ──
    ContactEventSummary:
      type: object
      title: Contact summary (webhook payloads)
      description: |
        The compact `contact` block carried by `contact.created` / `contact.updated`
        payloads — a FIXED scalar-core projection: never junction arrays (tags/groups),
        custom fields, or engine-maintained columns. Fetch the full row with
        `GET /v1/contacts/{id}`.
      required:
        - id
        - phone
        - email
        - name
        - first_name
        - last_name
        - lifecycle_stage
        - source
        - block_state
        - lead_score
      properties:
        id:
          type: string
          format: uuid
        phone:
          type: [string, "null"]
        email:
          type: [string, "null"]
        name:
          type: [string, "null"]
        first_name:
          type: [string, "null"]
        last_name:
          type: [string, "null"]
        lifecycle_stage:
          type: [string, "null"]
        source:
          type: [string, "null"]
        block_state:
          type: [string, "null"]
          description: '`none` / `workspace` / `global`.'
        lead_score:
          type: [number, "null"]
    ContactCreatedEvent:
      type: object
      title: contact.created event
      description: |
        Same envelope, headers, signing, and retry policy as every other event (see
        `EmailEventBase`). Like order events, `data` always carries the full field set
        (explicit `null`s).
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          const: contact.created
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true
          required: [contact_id, contact, source, duplicate]
          properties:
            contact_id:
              type: string
              format: uuid
            contact:
              $ref: "#/components/schemas/ContactEventSummary"
            source:
              type: string
              description: |
                Which surface created the contact — e.g. `manual`, `api`, `form`,
                `automation`, an integration name. Tolerate unknown values.
            duplicate:
              type: boolean
              description: |
                Always `false` — the event fires only for fresh inserts (an upsert
                that matched an existing contact emits `contact.updated`, when the
                write is an intentional seam).
    ContactUpdatedEvent:
      type: object
      title: contact.updated event
      description: |
        Same envelope, headers, signing, and retry policy as every other event (see
        `EmailEventBase`).
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          const: contact.updated
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true
          required: [contact_id, changed_fields, contact, source]
          properties:
            contact_id:
              type: string
              format: uuid
            changed_fields:
              type: array
              items:
                type: string
              description: |
                The changed field names — the same list the in-app Activity timeline
                records, including the `tags`/`groups` junction keys and
                `custom_fields.<key>` entries.
            contact:
              $ref: "#/components/schemas/ContactEventSummary"
            source:
              type: string
              description: Which surface performed the write. Tolerate unknown values.
    ContactDeletedEvent:
      type: object
      title: contact.deleted event
      description: |
        Same envelope, headers, signing, and retry policy as every other event (see
        `EmailEventBase`). Carries last-known identifiers only — the row is gone; key
        your mirror off `data.contact_id`.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          const: contact.deleted
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true
          required: [contact_id, phone, email, name]
          properties:
            contact_id:
              type: string
              format: uuid
            phone:
              type: [string, "null"]
            email:
              type: [string, "null"]
            name:
              type: [string, "null"]
    ContactConsentChangedEvent:
      type: object
      title: contact.consent_changed event
      description: |
        Same envelope, headers, signing, and retry policy as every other event (see
        `EmailEventBase`). Fires on real consent-state transitions plus
        deliverability-driven suppress escalations (which keep their prior state —
        compare `consent_state` with `previous_state` and key on `action`).
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          const: contact.consent_changed
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true
          required:
            - contact_id
            - channel
            - action
            - consent_state
            - previous_state
            - basis
            - source
            - consent_event_id
          properties:
            contact_id:
              type: string
              format: uuid
            channel:
              type: string
              enum: [whatsapp, email]
            action:
              type: string
              description: |
                The consent-ledger action that fired the event — e.g. `opt_in`,
                `double_opt_in_confirmed`, `unsubscribe`, `resubscribe`, `suppress`.
                Tolerate unknown values.
            consent_state:
              type: string
              enum: [subscribed, unsubscribed, unknown]
            previous_state:
              type: [string, "null"]
              enum: [subscribed, unsubscribed, unknown, null]
              description: "`null` when no prior decision was stored."
            basis:
              type: [string, "null"]
              enum: [express_opt_in, double_opt_in, soft_opt_in, implied, imported, null]
            source:
              type: string
              description: |
                Provenance of the decision — API writes read `api` or `api:<source>`;
                other surfaces stamp their own.
            consent_event_id:
              type: string
              format: uuid
              description: The consent-evidence ledger row this change wrote.

    # ── Message webhook events ──
    MessageReceivedEvent:
      type: object
      title: message.received event
      description: |
        Same envelope, headers, signing, and retry policy as every other event (see
        `EmailEventBase`). Real WhatsApp inbound only, exactly once per WhatsApp
        message. `data.media` is present on media messages only (omitted otherwise)
        and carries **metadata only** — never presigned URLs or storage keys.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          const: message.received
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true
          required:
            - message_id
            - conversation_id
            - contact_id
            - channel
            - type
            - text
            - wa_message_id
            - timestamp
          properties:
            message_id:
              type: string
              format: uuid
            conversation_id:
              type: [string, "null"]
              format: uuid
            contact_id:
              type: [string, "null"]
              format: uuid
            channel:
              const: whatsapp_api
              description: "v1 fires for the WhatsApp channel only."
            type:
              type: [string, "null"]
              description: |
                The message type — e.g. `text`, `image`, `video`, `audio`, `document`,
                `location`, `contacts`, `button`. Tolerate unknown values.
            text:
              type: [string, "null"]
              description: |
                The message's human text: body text for text/button messages, the
                caption for captioned media, else `null`.
            media:
              type: object
              description: |
                Media messages only — omitted otherwise. Metadata only, never URLs.
              required: [mime_type, filename, bytes]
              properties:
                mime_type:
                  type: [string, "null"]
                filename:
                  type: [string, "null"]
                bytes:
                  type: [integer, "null"]
            wa_message_id:
              type: [string, "null"]
              description: WhatsApp's own message id (`wamid.…`).
            timestamp:
              type: [string, "null"]
              format: date-time
              description: The Meta-provided message timestamp (ISO 8601 UTC).

    # ── Deal webhook events ──
    DealEventBase:
      type: object
      title: Deal event (base)
      description: |
        Every delivery is an HTTP POST with this JSON body — the same envelope
        (`id`/`type`/`created_at`/`data`), headers, signing, and retry policy as the
        email events (see `EmailEventBase`). Like order events, `data` always carries
        the **full field set** — absent values are explicit `null`s, never omitted
        keys. The `data` object is a snapshot of the deal at event time; money is a
        JSON number in the deal's currency, instants are ISO-8601 UTC or `null`.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          $ref: "#/components/schemas/DealEventType"
        created_at:
          type: string
          format: date-time
          description: When the event occurred.
        data:
          type: object
          additionalProperties: true
          required:
            - deal_id
            - contact_id
            - pipeline_id
            - pipeline_name
            - stage_id
            - stage_name
            - from_stage_id
            - from_stage_name
            - status
            - title
            - amount
            - currency
            - owner_user_id
            - external_reference
            - source
            - expected_close_at
            - closed_at
            - lost_reason
          properties:
            deal_id:
              type: string
              format: uuid
              description: The deal's `id`.
            contact_id:
              type: string
              format: uuid
            pipeline_id:
              type: string
              format: uuid
            pipeline_name:
              type: [string, "null"]
            stage_id:
              type: string
              format: uuid
              description: The deal's CURRENT stage (the destination, on a move).
            stage_name:
              type: [string, "null"]
            from_stage_id:
              type: [string, "null"]
              format: uuid
              description: "`deal.stage_changed` only — `null` on every other deal event."
            from_stage_name:
              type: [string, "null"]
              description: "`deal.stage_changed` only — `null` on every other deal event."
            status:
              type: string
              enum: [open, won, lost]
            title:
              type: [string, "null"]
            amount:
              type: [number, "null"]
              description: JSON number in the deal's currency.
            currency:
              type: [string, "null"]
            owner_user_id:
              type: [string, "null"]
              format: uuid
            external_reference:
              type: [string, "null"]
              description: The `/v1/deals` idempotency reference, when set.
            source:
              type: string
              description: |
                Which surface performed the write — `manual`, `api`, `automation`, or
                `salesforce`. Tolerate unknown values.
            expected_close_at:
              type: [string, "null"]
              format: date-time
              description: ISO 8601 UTC (instants below are ISO 8601 UTC or `null`).
            closed_at:
              type: [string, "null"]
              format: date-time
            lost_reason:
              type: [string, "null"]
    DealCreatedEvent:
      title: deal.created event
      allOf:
        - $ref: "#/components/schemas/DealEventBase"
        - type: object
          properties:
            type:
              const: deal.created
    DealStageChangedEvent:
      title: deal.stage_changed event
      allOf:
        - $ref: "#/components/schemas/DealEventBase"
        - type: object
          properties:
            type:
              const: deal.stage_changed
    DealWonEvent:
      title: deal.won event
      allOf:
        - $ref: "#/components/schemas/DealEventBase"
        - type: object
          properties:
            type:
              const: deal.won
    DealLostEvent:
      title: deal.lost event
      allOf:
        - $ref: "#/components/schemas/DealEventBase"
        - type: object
          properties:
            type:
              const: deal.lost

    # ── Booking webhook events ──
    BookingEventBase:
      type: object
      title: Booking event (base)
      description: |
        Every delivery is an HTTP POST with this JSON body — the same envelope
        (`id`/`type`/`created_at`/`data`), headers, signing, and retry policy as the
        email events (see `EmailEventBase`). Like order events, `data` always carries
        the **full field set** — explicit `null`s, never omitted keys. Instants are
        ISO-8601 UTC; the booking module is deliberately multi-timezone, so BOTH the
        host and invitee timezones ride the payload. There is deliberately **no
        `manage_url`** — that is a capability token and never leaves through a
        webhook body.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          $ref: "#/components/schemas/BookingEventType"
        created_at:
          type: string
          format: date-time
          description: When the event occurred.
        data:
          type: object
          additionalProperties: true
          required:
            - booking_id
            - contact_id
            - meeting_type_id
            - meeting_type_name
            - host_user_id
            - host_name
            - previous_host_name
            - start_at
            - end_at
            - host_timezone
            - invitee_timezone
            - status
            - location_type
            - cancelled_by
            - cancel_reason
            - source
          properties:
            booking_id:
              type: string
              format: uuid
              description: The booking's `id`.
            contact_id:
              type: string
              format: uuid
            meeting_type_id:
              type: [string, "null"]
              format: uuid
            meeting_type_name:
              type: [string, "null"]
            host_user_id:
              type: [string, "null"]
              format: uuid
            host_name:
              type: [string, "null"]
            previous_host_name:
              type: [string, "null"]
              description: "`booking.reassigned` only — `null` on every other booking event."
            start_at:
              type: [string, "null"]
              format: date-time
              description: ISO 8601 UTC (instants here are ISO 8601 UTC or `null`).
            end_at:
              type: [string, "null"]
              format: date-time
            host_timezone:
              type: [string, "null"]
              description: IANA timezone of the host's schedule.
            invitee_timezone:
              type: [string, "null"]
              description: IANA timezone the invitee booked in.
            status:
              type: [string, "null"]
              description: |
                Booking status at event time — e.g. `confirmed`, `cancelled`.
                Tolerate unknown values.
            location_type:
              type: [string, "null"]
              description: The meeting type's location kind. Tolerate unknown values.
            cancelled_by:
              type: [string, "null"]
              description: |
                Who cancelled (`booking.cancelled`) — e.g. `host`, `invitee` — `null`
                elsewhere.
            cancel_reason:
              type: [string, "null"]
            source:
              type: [string, "null"]
              description: |
                How the booking was created — `public_page`, `manual`, `api`, or
                `embed`. Passed through verbatim: **tolerate unknown values**, new
                sources may appear without a spec bump.
    BookingCreatedEvent:
      title: booking.created event
      allOf:
        - $ref: "#/components/schemas/BookingEventBase"
        - type: object
          properties:
            type:
              const: booking.created
    BookingRescheduledEvent:
      title: booking.rescheduled event
      allOf:
        - $ref: "#/components/schemas/BookingEventBase"
        - type: object
          properties:
            type:
              const: booking.rescheduled
    BookingCancelledEvent:
      title: booking.cancelled event
      allOf:
        - $ref: "#/components/schemas/BookingEventBase"
        - type: object
          properties:
            type:
              const: booking.cancelled
    BookingReassignedEvent:
      title: booking.reassigned event
      allOf:
        - $ref: "#/components/schemas/BookingEventBase"
        - type: object
          properties:
            type:
              const: booking.reassigned

    # ── Event-attendance webhook events ──
    EventAttendanceChangedEvent:
      type: object
      title: event.attendance.changed event
      description: |
        Same envelope, headers, signing, and retry policy as every other event (see
        `EmailEventBase`). ONE event type for the whole family — `data.status` /
        `data.previous_status` carry the transition. Instants are ISO-8601 UTC or
        `null`.
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          const: event.attendance.changed
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true
          required:
            - attendance_id
            - event_id
            - contact_id
            - status
            - previous_status
            - registered_at
            - attended_at
            - unregistered_at
            - event
          properties:
            attendance_id:
              type: string
              format: uuid
            event_id:
              type: string
              format: uuid
            contact_id:
              type: string
              format: uuid
            status:
              type: [string, "null"]
              description: |
                `registered`, `attending`, `attended`, `no_show`, or `cancelled`.
                Tolerate unknown values.
            previous_status:
              type: [string, "null"]
              description: |
                `null` for fresh registrations and for set-based bulk status updates
                (whose single UPDATE has no per-row prior state).
            registered_at:
              type: [string, "null"]
              format: date-time
            attended_at:
              type: [string, "null"]
              format: date-time
            unregistered_at:
              type: [string, "null"]
              format: date-time
            event:
              type: [object, "null"]
              description: A compact snapshot of the event — `null` when unavailable.
              required: [id, name, start_at]
              properties:
                id:
                  type: string
                  format: uuid
                name:
                  type: [string, "null"]
                start_at:
                  type: [string, "null"]
                  format: date-time

    # ── Form webhook events ──
    FormSubmittedEvent:
      type: object
      title: form.submitted event
      description: |
        Same envelope, headers, signing, and retry policy as every other event (see
        `EmailEventBase`). ONE event type for every submission surface — `data.origin`
        is the discriminator. Fires even when no contact was resolved
        (`data.contact_id` is then `null`).
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: |
            Event id — stable across retries and shared across your endpoints. Use it
            as your dedup key.
        type:
          const: form.submitted
        created_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true
          required:
            - form_id
            - form_name
            - submission_id
            - contact_id
            - origin
            - landing_page_id
            - popup_id
            - fields
          properties:
            form_id:
              type: string
              format: uuid
            form_name:
              type: string
            submission_id:
              type: string
              description: Unique per submission — a secondary dedup key for your CRM.
            contact_id:
              type: [string, "null"]
              format: uuid
              description: "`null` when contact auto-creation is off and no contact matched."
            origin:
              type: string
              enum: [form, landing_page, popup]
              description: |
                `form` = standalone embed, `landing_page` = a published landing page's
                form block, `popup` = an on-site popup.
            landing_page_id:
              type: [string, "null"]
              format: uuid
              description: 'Set when `origin` is `landing_page`, else `null`.'
            popup_id:
              type: [string, "null"]
              format: uuid
              description: 'Set when `origin` is `popup`, else `null`.'
            fields:
              type: object
              additionalProperties: true
              description: The submitted answers, keyed by form field ids.

    # ── Meeting types & bookings ──
    MeetingType:
      type: object
      title: Meeting type
      description: |
        Internal configuration (schedules, booking-page questions, buffers, etc.) is not
        exposed. Note: the public docs describe `scheduling_kind` as `single` or "a team
        kind such as round-robin/collective" without enumerating the exact string values;
        it is modeled as a free string here.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: [string, "null"]
        description:
          type: [string, "null"]
        duration_minutes:
          type: integer
        location_type:
          type: [string, "null"]
          description: Value set not enumerated in the public docs (example shows `zoom`).
        is_active:
          type: boolean
        scheduling_kind:
          type: string
          description: '`single` (default; one host), or a team kind such as round-robin/collective.'
        host:
          type: object
          description: Back-compat convenience; its `id` is `null` for team kinds.
          properties:
            id:
              type: [string, "null"]
              format: uuid
            name:
              type: [string, "null"]
        hosts:
          type: array
          description: The configured host pool (exactly one entry for `single`).
          items:
            type: object
            properties:
              user_id:
                type: string
                format: uuid
              name:
                type: string
        created_at:
          type: string
          format: date-time
      additionalProperties: true
    SlotsResult:
      type: object
      title: Open slots
      properties:
        meeting_type_id:
          type: string
          format: uuid
        timezone:
          type: string
          description: The host schedule's IANA zone.
        duration_minutes:
          type: integer
        slots:
          type: array
          items:
            type: object
            properties:
              start:
                type: string
                format: date-time
              end:
                type: string
                format: date-time
    MeetingTypeEmbed:
      type: object
      title: Meeting type embed material
      description: |
        `embed_key` is the workspace's **publishable** embed key (`bk_…`) — safe to
        ship in page HTML by design, and NOT the secret API key. Rotation, the origin
        allowlist, and the embed on/off switch live in the oToK app under
        Settings → Booking.
      properties:
        workspace_ref:
          type: string
          description: The workspace's public ref used in hosted booking URLs.
        slug:
          type: string
          description: The meeting type's slug.
        embed_key:
          type: string
          description: Publishable workspace embed key (`bk_…`) — NOT the secret API key.
        page_url:
          type: string
          format: uri
          description: The hosted booking page URL for this meeting type.
        snippet_html:
          type: string
          description: Ready-to-paste two-line HTML embed snippet.
    Booking:
      type: object
      title: Booking
      description: |
        Internal fields (calendar ids, invitee management tokens, booking-page answers) are
        never exposed. `hosts` lists the assigned host first, then any co-hosts.
      properties:
        id:
          type: string
          format: uuid
        meeting_type_id:
          type: string
          format: uuid
        contact_id:
          type: [string, "null"]
          format: uuid
        host_user_id:
          type: [string, "null"]
          format: uuid
        hosts:
          type: array
          items:
            type: object
            properties:
              user_id:
                type: string
                format: uuid
              name:
                type: string
              role:
                type: string
                description: 'e.g. "assigned"; the full role value set is not enumerated in the public docs.'
        status:
          type: string
          enum: [confirmed, cancelled, completed, no_show]
        start_at:
          type: string
          format: date-time
        end_at:
          type: string
          format: date-time
        host_timezone:
          type: [string, "null"]
        invitee_timezone:
          type: [string, "null"]
        invitee_name:
          type: [string, "null"]
        invitee_email:
          type: [string, "null"]
        invitee_phone:
          type: [string, "null"]
        join_url:
          type: [string, "null"]
        notes:
          type: [string, "null"]
        source:
          type: [string, "null"]
        cancelled_at:
          type: [string, "null"]
          format: date-time
        cancelled_by:
          type: [string, "null"]
          description: The docs do not document the value type (user id vs role label).
        cancellation_reason:
          type: [string, "null"]
        rescheduled_at:
          type: [string, "null"]
          format: date-time
        previous_start_at:
          type: [string, "null"]
          format: date-time
        reschedule_count:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      additionalProperties: true
    BookingCreateResult:
      title: Booking create result
      description: |
        `POST /v1/bookings` response — the booking object plus the top-level `duplicate`
        marker. Both outcomes return 201.
      allOf:
        - $ref: "#/components/schemas/Booking"
        - type: object
          required: [duplicate]
          properties:
            duplicate:
              type: boolean
              description: |
                `false` when this request created the booking; `true` when a double
                submit of the same slot/invitee returned the original booking.
    BookingCreate:
      type: object
      title: Booking create payload
      description: Provide `contact_id` or an `invitee` object (when both are given, `contact_id` wins).
      additionalProperties: false
      required: [meeting_type_id, start_at, timezone]
      properties:
        meeting_type_id:
          type: string
          format: uuid
          description: Must be active.
        start_at:
          type: string
          format: date-time
          description: Must be an open slot (use the slots endpoint).
        timezone:
          type: string
          description: Valid IANA zone (e.g. `Europe/Berlin`) — recorded as the invitee's timezone.
        contact_id:
          type: string
          format: uuid
          description: Existing contact.
        invitee:
          type: object
          additionalProperties: false
          required: [name, email]
          description: |
            Upserted into contacts by phone/email; the email receives the confirmation and
            manage link.
          properties:
            name:
              type: string
              maxLength: 200
            email:
              type: string
              format: email
              maxLength: 320
            phone:
              type: string
              maxLength: 40
        notes:
          type: string
          maxLength: 2000
        host_user_id:
          type: string
          format: uuid
          description: |
            **Round-robin meeting types only:** pin the booking to this pool host (must be
            an active pool member with the slot free). Pinned bookings still count toward
            round-robin fairness.
