openapi: 3.1.0
info:
  title: Nitrosend API
  version: 1.0.0
  description: |
    Multi-channel marketing automation API. Send email campaigns, build
    automation flows, manage contacts and segments, track events, and
    configure your brand — all via a single REST API.

    ## Authentication

    All `/v1/my/*` endpoints require authentication via **Bearer token**.
    Two token types are accepted (tried in order):

    1. **API Key** — `Authorization: Bearer nskey_live_...`
    2. **JWT** — `Authorization: Bearer <jwt>` (obtained from `POST /v1/login`)

    ## Pagination

    Paginated endpoints return these headers:
    - `X-Total-Count` — total records
    - `X-Total-Pages` — total pages
    - `X-Page-Number` — current page
    - `X-Next-Page` — next page (omitted on last page)
    - `X-Prev-Page` — previous page (omitted on first page)

    Use `page` and `limit` (or `per`) query params to control pagination.
    Maximum `limit` is 100, default is 30.

    ## Error Responses

    All errors return a consistent JSON shape:
    ```json
    {
      "code": 422,
      "message": "Description of error",
      "error": true,
      "validation_errors": { "field": ["error message"] }
    }
    ```
    The `validation_errors` key is only present on 422 responses.

  contact:
    name: Nitrosend
    url: https://nitrosend.com
  license:
    name: Proprietary
    url: https://nitrosend.com/terms

servers:
  - url: https://api.nitrosend.com
    description: Production
  - url: http://localhost:8000
    description: Local development

security:
  - BearerAuth: []

tags:
  - name: Auth
    description: Login, logout, and registration
  - name: User
    description: Current user profile
  - name: Account
    description: Account settings and configuration
  - name: Contacts
    description: Contact management
  - name: Imports
    description: Bulk CSV import jobs
  - name: Lists
    description: Contact lists
  - name: Segments
    description: Dynamic contact segments
  - name: Campaigns
    description: Email and SMS campaigns
  - name: Messages
    description: Transactional email and SMS messages
  - name: Templates
    description: Email templates, preview, and component schema
  - name: Flows
    description: Automation flows and step schema
  - name: Events
    description: Contact event tracking
  - name: Domains
    description: Sending domain verification
  - name: Integrations
    description: BYO provider integration management
  - name: Brands
    description: Brand identity, theme, and per-brand configuration
  - name: Public
    description: Unauthenticated public endpoints

# =============================================================================
# PATHS
# =============================================================================
paths:
  # ---------------------------------------------------------------------------
  # Auth
  # ---------------------------------------------------------------------------
  /v1/signup:
    post:
      tags: [Auth]
      operationId: signup
      summary: Create a new account
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user]
              properties:
                user:
                  type: object
                  required: [email, password]
                  properties:
                    first_name: { type: string }
                    last_name: { type: string }
                    email: { type: string, format: email }
                    mobile: { type: string }
                    invite_token: { type: string, nullable: true }
                    password: { type: string, format: password, minLength: 6 }
                    password_confirmation: { type: string, format: password }
      responses:
        "201":
          description: Account created
          headers:
            Authorization:
              schema: { type: string }
              description: JWT token for the new session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/login:
    post:
      tags: [Auth]
      operationId: login
      summary: Sign in and receive a JWT
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user]
              properties:
                user:
                  type: object
                  required: [email, password]
                  properties:
                    email: { type: string, format: email }
                    password: { type: string, format: password }
      responses:
        "200":
          description: Signed in
          headers:
            Authorization:
              schema: { type: string }
              description: JWT token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/logout:
    delete:
      tags: [Auth]
      operationId: logout
      summary: Sign out and revoke JWT
      responses:
        "204":
          description: Signed out

  /v1/oauth/me:
    get:
      tags: [Auth]
      operationId: getOauthPopupContext
      summary: Get OAuth popup context
      description: |
        Returns the account-selection and subscription context used by the
        `/oauth/connect` popup. This endpoint accepts the normal Bearer JWT
        and, for popup resume flows, the authenticated browser session cookie
        established by Devise/OmniAuth.
      responses:
        "200":
          description: Popup context
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthPopupContext" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/oauth/subscribe:
    post:
      tags: [Auth]
      operationId: subscribeOauthPopupAccount
      summary: Subscribe the selected OAuth popup account
      description: |
        Finalizes plan selection for the `/oauth/connect` popup before the
        browser returns to the Doorkeeper consent screen. This endpoint accepts
        the normal Bearer JWT and, for popup resume flows, the authenticated
        browser session cookie established by Devise/OmniAuth.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthPopupSubscribeRequest" }
      responses:
        "200":
          description: Popup can continue to consent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthPopupSubscribeResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Account or plan selection failed
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string }

  /v1/oauth/launch:
    post:
      tags: [Auth]
      operationId: createOauthLaunch
      summary: Create a CSRF-safe OAuth provider launch URL
      security: []
      description: |
        Mints a short-lived launch URL for Google or GitHub sign-in. The
        frontend follows the returned API-origin launch page, which renders the
        real POST form to `/auth/:provider` with a valid Rails authenticity
        token. This preserves OmniAuth's POST-only request phase and CSRF
        protection for both the app popup flow and the `/oauth/connect` agent
        popup flow.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthLaunchRequest" }
      responses:
        "200":
          description: Launch URL created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthLaunchResponse" }
        "400":
          description: Invalid provider or resume URL
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string }

  # ---------------------------------------------------------------------------
  # User
  # ---------------------------------------------------------------------------
  /v1/my/user:
    get:
      tags: [User]
      operationId: getUser
      summary: Get current user profile
      responses:
        "200":
          description: User profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401":
          $ref: "#/components/responses/Unauthorized"

    patch:
      tags: [User]
      operationId: updateUser
      summary: Update current user profile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: string }
                last_name: { type: string }
                email: { type: string, format: email }
                mobile: { type: string }
      responses:
        "200":
          description: Updated user
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "422":
          $ref: "#/components/responses/ValidationError"

  # ---------------------------------------------------------------------------
  # Account
  # ---------------------------------------------------------------------------
  /v1/my/account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Get account details
      responses:
        "200":
          description: Account with brands and billing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }

    patch:
      tags: [Account]
      operationId: updateAccount
      summary: Update account settings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                avatar: { type: string, description: Signed blob ID }
                banner: { type: string, description: Signed blob ID }
      responses:
        "200":
          description: Updated account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/accounts:
    get:
      tags: [Account]
      operationId: listAccounts
      summary: List accessible accounts
      responses:
        "200":
          description: Accessible accounts
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Account" }

  /v1/my/account/team:
    get:
      tags: [Account]
      operationId: getAccountTeam
      summary: Get team metadata for the current account
      responses:
        "200":
          description: Team metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountTeam" }

  /v1/my/account/memberships:
    get:
      tags: [Account]
      operationId: listAccountMemberships
      summary: List account memberships
      responses:
        "200":
          description: Memberships
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AccountMembership" }

  /v1/my/account/memberships/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    patch:
      tags: [Account]
      operationId: updateAccountMembership
      summary: Update a membership role
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { type: string, enum: [member, admin] }
      responses:
        "200":
          description: Updated membership
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountMembership" }
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Account]
      operationId: removeAccountMembership
      summary: Remove a membership
      responses:
        "200":
          description: Removed membership
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountMembership" }

  /v1/my/account/invites:
    get:
      tags: [Account]
      operationId: listAccountInvites
      summary: List account invites
      responses:
        "200":
          description: Invites
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AccountInvite" }
    post:
      tags: [Account]
      operationId: createAccountInvite
      summary: Create an account invite
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [member, admin] }
      responses:
        "201":
          description: Created invite
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountInvite" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/account/invites/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    delete:
      tags: [Account]
      operationId: revokeAccountInvite
      summary: Revoke an invite
      responses:
        "200":
          description: Revoked invite
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountInvite" }

  /v1/my/account/invites/{id}/accept:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Account]
      operationId: acceptAccountInvite
      summary: Accept an invite token
      responses:
        "200":
          description: Accepted invite
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountInvite" }
        "422":
          $ref: "#/components/responses/ValidationError"

  # ---------------------------------------------------------------------------
  # Contacts
  # ---------------------------------------------------------------------------
  /v1/my/contacts:
    get:
      tags: [Contacts]
      operationId: listContacts
      summary: List contacts (paginated)
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 100 }
        - name: search
          in: query
          schema: { type: string }
          description: Full-text search across name, email, phone
        - name: list_id
          in: query
          schema: { type: integer }
          description: Filter contacts belonging to this list
        - name: tag
          in: query
          schema: { type: string }
          description: |
            Filter contacts that have this tag. Tags are stored as an array of
            strings under `data.tags`. Only one `tag` filter is supported here;
            for more complex tag-based targeting, create a Segment with the
            `contact_tag` filter and the `cont` predicate.
      responses:
        "200":
          description: Paginated contacts
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Contact" }

    post:
      tags: [Contacts]
      operationId: createContact
      summary: Create a contact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: string }
                last_name: { type: string }
                email: { type: string, format: email, description: "Creates/updates email channel with opt_in" }
                phone: { type: string, description: "E.164 format. Creates/updates phone channel with opt_in" }
                source: { type: string }
                country_code: { type: string }
                list_ids:
                  type: array
                  items: { type: integer }
                data:
                  type: object
                  additionalProperties: true
                  description: |
                    Custom key-value data. The reserved key `tags` holds an
                    array of string labels used for segmentation — e.g.
                    `{"tags": ["vip", "newsletter"]}`. Writing `data.tags`
                    replaces the contact's existing tag list.
                  example:
                    tags: ["vip", "newsletter"]
                    plan: "pro"
                channels_attributes:
                  type: array
                  items:
                    type: object
                    properties:
                      kind: { type: string, enum: [email, phone] }
                      value: { type: string }
                      subscribed: { type: boolean }
      responses:
        "201":
          description: Contact created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/contacts/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Contacts]
      operationId: getContact
      summary: Get a contact
      responses:
        "200":
          description: Contact with channels
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Contacts]
      operationId: updateContact
      summary: Update a contact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: string }
                last_name: { type: string }
                email: { type: string, format: email }
                phone: { type: string }
                source: { type: string }
                country_code: { type: string }
                list_ids:
                  type: array
                  items: { type: integer }
                data:
                  type: object
                  additionalProperties: true
                  description: |
                    Custom key-value data. The reserved key `tags` holds an
                    array of string labels used for segmentation — e.g.
                    `{"tags": ["vip", "newsletter"]}`.

                    **The `data` object is replaced wholesale on update.**
                    Sending a partial `data` hash will overwrite any other
                    keys currently stored (e.g. `data.plan`, enrichment
                    metadata). To change a single key, GET the contact, merge
                    your change into the existing `data`, then PATCH the full
                    merged object back. For add/remove tag semantics across
                    many contacts, use the `nitro_manage_audience` MCP tool
                    with `operation: "bulk_tag"`.
                  example:
                    tags: ["vip", "newsletter"]
                    plan: "pro"
                channels_attributes:
                  type: array
                  items:
                    type: object
                    properties:
                      id: { type: integer }
                      kind: { type: string, enum: [email, phone] }
                      value: { type: string }
                      subscribed: { type: boolean }
                      _destroy: { type: boolean }
      responses:
        "200":
          description: Updated contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Contacts]
      operationId: deleteContact
      summary: Delete a contact
      responses:
        "200":
          description: Deleted contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }

  /v1/my/contacts/{id}/enrich:
    post:
      tags: [Contacts]
      operationId: enrichContact
      summary: Re-enrich a contact via email verification + Apollo
      description: |
        Queues the contact for email verification and Apollo enrichment.
        Consumes 2 validation allowance units. Requires the contact to have
        an email channel.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Enrichment queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400":
          description: Contact has no email or validation allowance exhausted
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ---------------------------------------------------------------------------
  # Imports
  # ---------------------------------------------------------------------------
  /v1/my/imports:
    get:
      tags: [Imports]
      operationId: listImports
      summary: List import jobs
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated import jobs
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Import" }

    post:
      tags: [Imports]
      operationId: createImport
      summary: Start a CSV import
      description: |
        Uploads a CSV file and enqueues an import job. Poll `GET
        /v1/my/imports/{id}` for status, row counts, and row-level errors.

        For contact imports, pass `options` as a JSON object or JSON string
        with `list_ids` to assign imported contacts to one or more lists,
        e.g. `{"list_ids":[88]}`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: CSV file, maximum 10 MB
                resource:
                  type: string
                  enum: [contacts]
                  default: contacts
                parser:
                  type: string
                  enum: [default]
                  default: default
                dry_run:
                  type: boolean
                  default: false
                columns:
                  description: CSV column mapping as a JSON object or JSON string.
                  oneOf:
                    - type: object
                      additionalProperties: { type: string }
                    - type: string
                  example:
                    email: Email
                    first_name: First Name
                options:
                  description: Import options as a JSON object or JSON string.
                  oneOf:
                    - type: object
                      properties:
                        list_ids:
                          type: array
                          items: { type: integer }
                    - type: string
                  example:
                    list_ids: [88]
      responses:
        "201":
          description: Import queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Import" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/imports/spec:
    get:
      tags: [Imports]
      operationId: getImportSpec
      summary: Get import schema metadata
      parameters:
        - name: resource
          in: query
          schema: { type: string, enum: [contacts] }
          description: Optional resource name. Omit to list all schemas.
      responses:
        "200":
          description: Import schema metadata
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/imports/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Imports]
      operationId: getImport
      summary: Get import job status
      responses:
        "200":
          description: Import job with status, counts, and row errors
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Import" }
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Imports]
      operationId: deleteImport
      summary: Delete an import record
      responses:
        "200":
          description: Deleted import
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Import" }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/my/imports/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Imports]
      operationId: cancelImport
      summary: Cancel a pending or processing import
      responses:
        "200":
          description: Canceled import
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Import" }
        "422":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"

  # ---------------------------------------------------------------------------
  # Lists
  # ---------------------------------------------------------------------------
  /v1/my/lists:
    get:
      tags: [Lists]
      operationId: listLists
      summary: List contact lists (paginated)
      parameters:
        - name: name
          in: query
          schema: { type: string }
          description: Exact case-insensitive list name filter
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated contact lists
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ContactList" }

    post:
      tags: [Lists]
      operationId: createList
      summary: Create a contact list
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                segment_id: { type: integer, nullable: true }
                contact_ids:
                  type: array
                  items: { type: integer }
      responses:
        "201":
          description: List created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/lists/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Lists]
      operationId: getList
      summary: Get a contact list
      responses:
        "200":
          description: Contact list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Lists]
      operationId: updateList
      summary: Update a contact list
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                segment_id: { type: integer, nullable: true }
                contact_ids:
                  type: array
                  items: { type: integer }
      responses:
        "200":
          description: Updated list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Lists]
      operationId: deleteList
      summary: Delete a contact list
      responses:
        "200":
          description: Deleted list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }

  /v1/my/lists/{id}/delete_warning:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Lists]
      operationId: getListDeleteWarning
      summary: Get delete warning metadata for a contact list
      responses:
        "200":
          description: Flows connected to this list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListDeleteWarning"

  /v1/my/lists/{id}/contacts/bulk:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Lists]
      operationId: bulkListContacts
      summary: Add or remove existing contacts from a list by email
      description: |
        Public REST equivalent of the list membership batch primitive. The
        endpoint resolves existing contacts by email within the current brand
        and adds or removes memberships idempotently. It does not create
        contacts; emails with no current-brand contact are returned in
        `not_found`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkListContactsRequest" }
      responses:
        "200":
          description: Bulk list membership result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkListContactsResponse" }
        "422":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"

  # ---------------------------------------------------------------------------
  # Segments
  # ---------------------------------------------------------------------------
  /v1/my/segments:
    get:
      tags: [Segments]
      operationId: listSegments
      summary: List all segments
      responses:
        "200":
          description: Segments
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Segment" }

    post:
      tags: [Segments]
      operationId: createSegment
      summary: Create a segment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                filters:
                  $ref: "#/components/schemas/SegmentFilters"
      responses:
        "201":
          description: Segment created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Segment" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/segments/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Segments]
      operationId: getSegment
      summary: Get a segment
      responses:
        "200":
          description: Segment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Segment" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Segments]
      operationId: updateSegment
      summary: Update a segment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                filters:
                  $ref: "#/components/schemas/SegmentFilters"
      responses:
        "200":
          description: Updated segment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Segment" }
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Segments]
      operationId: deleteSegment
      summary: Delete a segment
      responses:
        "200":
          description: Deleted segment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Segment" }

  /v1/my/segments/count:
    post:
      tags: [Segments]
      operationId: countSegment
      summary: Count contacts matching filters
      description: |
        Preview how many contacts match a set of segment filters
        without creating or saving a segment.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                filters:
                  $ref: "#/components/schemas/SegmentFilters"
      responses:
        "200":
          description: Contact count
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }

  # ---------------------------------------------------------------------------
  # Campaigns
  # ---------------------------------------------------------------------------
  /v1/my/campaigns:
    get:
      tags: [Campaigns]
      operationId: listCampaigns
      summary: List campaigns (paginated)
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated campaigns
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Campaign" }

    post:
      tags: [Campaigns]
      operationId: createCampaign
      summary: Create a campaign
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                channel: { type: string, enum: [email, sms], default: email }
      responses:
        "201":
          description: Campaign created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/campaigns/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Campaigns]
      operationId: getCampaign
      summary: Get a campaign
      responses:
        "200":
          description: Campaign with trigger, template, and templates
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Campaigns]
      operationId: updateCampaign
      summary: Update a campaign
      description: |
        Updates the campaign. When the campaign is no longer editable (see the
        `editable` field on the Campaign schema — false for live/paused/
        completed/cancelled/archived, and for scheduled campaigns within 5
        minutes of their send time), only name-only payloads and status
        transitions (Resume, Cancel) are accepted. Any other attribute returns
        422 with `error_code: "campaign_locked"`. Use the `/duplicate` endpoint
        to fork a sent campaign into a new draft.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                status: { type: string }
                channel: { type: string, enum: [email, sms] }
                scheduled_at: { type: string, format: date-time, nullable: true }
                trigger_attributes:
                  type: object
                  properties:
                    event: { type: string }
                    audience_type:
                      type: string
                      nullable: true
                      enum: [lists, segment, all_contacts]
                      description: "Explicit campaign audience target. Use all_contacts only for deliberate all-subscribed-contact sends."
                    contact_list_id: { type: integer, nullable: true, deprecated: true }
                    contact_list_ids:
                      type: array
                      items: { type: integer }
                    segment_id: { type: integer, nullable: true }
                    data: { type: object, additionalProperties: true }
                template_attributes:
                  type: object
                  properties:
                    subject: { type: string }
                    body: { type: string }
                    preheader: { type: string }
                    from_name: { type: string }
                    from_email: { type: string, format: email }
                    reply_to: { type: string, format: email }
                    design:
                      $ref: "#/components/schemas/EmailDesign"
      responses:
        "200":
          description: Updated campaign
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Campaigns]
      operationId: deleteCampaign
      summary: Delete a campaign
      responses:
        "200":
          description: Deleted campaign
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }

  /v1/my/campaigns/{id}/duplicate:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Campaigns]
      operationId: duplicateCampaign
      summary: Duplicate a campaign
      description: |
        Creates a new draft campaign that copies the source's audience
        (trigger.audience_type, contact_list_ids, segment_id) and template
        content (design, subject, preheader, body, from_name, from_email,
        reply_to). Resets status to draft, approval_state to pending_review,
        scheduled_at to null, and trigger.event to manual. Works on any
        status — this is how clients fork a sent campaign into a new draft.
        Sessions, activities, approvals, and metrics are not copied.

        Pass `cancel_source: true` to atomically cancel the source campaign
        in the same transaction as the duplicate — useful for 'cancel and
        duplicate' flows on paused campaigns.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                cancel_source:
                  type: boolean
                  default: false
                  description: When true, cancels the source campaign in the same DB transaction as the duplicate creation.
      responses:
        "201":
          description: New draft campaign
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/my/campaigns/{id}/send:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Campaigns]
      operationId: sendCampaign
      summary: Send a campaign
      description: |
        Transitions the campaign and its flow to live, then delivers.
        Optionally update trigger and template attributes in the same request.
        Returns 422 `campaign_locked` if the campaign is not editable
        (live, paused, completed, cancelled, archived, or scheduled within
        5 minutes of send).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                confirm_send_to_all:
                  type: boolean
                  description: "Required when trigger_attributes.audience_type or the saved trigger audience_type is all_contacts."
                trigger_attributes:
                  type: object
                  properties:
                    event: { type: string }
                    audience_type:
                      type: string
                      nullable: true
                      enum: [lists, segment, all_contacts]
                      description: "Explicit campaign audience target. Use all_contacts only for deliberate all-subscribed-contact sends."
                    contact_list_id: { type: integer, nullable: true, deprecated: true }
                    contact_list_ids:
                      type: array
                      items: { type: integer }
                    segment_id: { type: integer, nullable: true }
                    data: { type: object, additionalProperties: true }
                template_attributes:
                  type: object
                  properties:
                    subject: { type: string }
                    body: { type: string }
                    preheader: { type: string }
                    from_name: { type: string }
                    from_email: { type: string, format: email }
                    reply_to: { type: string, format: email }
                    design:
                      $ref: "#/components/schemas/EmailDesign"
      responses:
        "200":
          description: Campaign sent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "422":
          $ref: "#/components/responses/ValidationError"

  # ---------------------------------------------------------------------------
  # Templates
  # ---------------------------------------------------------------------------
  /v1/my/templates:
    get:
      tags: [Templates]
      operationId: listTemplates
      summary: List all templates
      description: Returns a summary view with section counts (not full design).
      responses:
        "200":
          description: Template summaries
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/TemplateSummary" }

    post:
      tags: [Templates]
      operationId: createTemplate
      summary: Create a standalone template
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                subject: { type: string }
                preheader: { type: string }
                generation_id: { type: integer, nullable: true }
                design:
                  $ref: "#/components/schemas/EmailDesign"
      responses:
        "201":
          description: Created template
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/templates/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Templates]
      operationId: getTemplate
      summary: Get a template with full design
      responses:
        "200":
          description: Full template including design
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Templates]
      operationId: updateTemplate
      summary: Update a template
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                subject: { type: string }
                body: { type: string }
                preheader: { type: string }
                from_name: { type: string }
                from_email: { type: string, format: email }
                reply_to: { type: string, format: email }
                generation_id: { type: integer, nullable: true }
                design:
                  $ref: "#/components/schemas/EmailDesign"
      responses:
        "200":
          description: Updated template
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/templates/{id}/send_test:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Templates]
      operationId: sendTestEmail
      summary: Send a test email
      description: |
        Send a test email for the given template. Provide `email` for an
        explicit recipient, `contact_id` to use a contact's email (with
        merge-tag personalization), or omit both to send to the account owner.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                contact_id: { type: integer }
      responses:
        "200":
          description: Test email sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: integer }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        email: { type: string }
                        success: { type: boolean }
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/templates/preview:
    post:
      tags: [Templates]
      operationId: previewEmail
      summary: Render an email design to HTML
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document]
              properties:
                document:
                  $ref: "#/components/schemas/EmailDesign"
      responses:
        "200":
          description: Rendered HTML
          content:
            application/json:
              schema:
                type: object
                properties:
                  html: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/my/templates/spec:
    get:
      tags: [Templates]
      operationId: getEmailComponentSpec
      summary: Get the email component schema
      description: |
        Returns the full schema for email design sections including all
        component types, their props, required fields, and defaults.
        Sourced from `config/email_components.yml`.
      responses:
        "200":
          description: Email component schema
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailComponentSpec"

  /v1/my/templates/starters:
    get:
      tags: [Templates]
      operationId: listEmailStarters
      summary: List email starter designs
      description: |
        Returns config-based starter email designs with the current brand's
        theme merged and an HTML preview rendered for each starter.
      responses:
        "200":
          description: Starter designs with brand theme and preview HTML
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/EmailStarter" }

  /v1/my/templates/generate:
    post:
      tags: [Templates]
      operationId: generateEmailTemplate
      summary: AI-generate an email template
      description: |
        Generate a complete email design from a text goal using AI. Returns a
        sections-based design, subject line, preheader, and inferred category.
        Supports refine mode by passing existing sections[] for modification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [goal]
              properties:
                goal:
                  type: string
                  description: What the email should accomplish
                  example: "Welcome new subscribers and introduce the brand"
                category:
                  type: string
                  description: Optional category hint (welcome, newsletter, promotion, etc.)
                tone:
                  type: string
                  description: Optional tone override (formal, casual, etc.)
                sections:
                  type: array
                  description: Current sections for refine mode — LLM adjusts existing content
                  items:
                    type: object
      responses:
        "200":
          description: Generated email design
          content:
            application/json:
              schema:
                type: object
                properties:
                  design:
                    type: object
                    description: "{ version, theme, sections[] } — ready for GrapesJS"
                  subject:
                    type: string
                  preheader:
                    type: string
                  category:
                    type: string
                    description: Inferred or provided category
                  prompt_version:
                    type: string
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                ai_limit_reached:
                  value: { error: true, code: "ai_limit_reached", message: "Generation limit reached." }
                brand_incomplete:
                  value: { error: true, code: "brand_incomplete", message: "Complete brand setup first." }
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Generation failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                generation_failed:
                  value: { error: true, code: "generation_failed", message: "Generation failed. Try again." }

  # ---------------------------------------------------------------------------
  # Flows
  # ---------------------------------------------------------------------------
  /v1/my/flows:
    get:
      tags: [Flows]
      operationId: listFlows
      summary: List automation flows (paginated)
      description: Returns standalone flows only (excludes campaign-attached flows).
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated flows
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Flow" }

    post:
      tags: [Flows]
      operationId: createFlow
      summary: Create a flow
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                status: { type: string, enum: [draft, active, paused, archived] }
                trigger:
                  $ref: "#/components/schemas/FlowTriggerInput"
                steps:
                  type: array
                  items:
                    $ref: "#/components/schemas/FlowStepInput"
      responses:
        "201":
          description: Flow created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Flow" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/flows/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Flows]
      operationId: getFlow
      summary: Get a flow
      responses:
        "200":
          description: Flow with full graph
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Flow" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Flows]
      operationId: updateFlow
      summary: Update a flow
      description: |
        Supports optimistic concurrency — pass `updated_at` to reject
        the update if the flow was modified externally.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                status: { type: string, enum: [draft, active, paused, archived] }
                updated_at:
                  type: string
                  format: date-time
                  description: Optimistic concurrency check
                trigger:
                  $ref: "#/components/schemas/FlowTriggerInput"
                steps:
                  type: array
                  items:
                    $ref: "#/components/schemas/FlowStepInput"
      responses:
        "200":
          description: Updated flow
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Flow" }
        "409":
          description: Conflict — flow was modified externally
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Flows]
      operationId: deleteFlow
      summary: Delete a flow
      responses:
        "200":
          description: Deleted flow
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Flow" }

  /v1/my/flows/spec:
    get:
      tags: [Flows]
      operationId: getFlowSpec
      summary: Get the flow step type schema
      description: |
        Returns the schema for flow step types, trigger events, and
        segment filters. Sourced from `config/flows.yml`.
      responses:
        "200":
          description: Flow specification
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FlowSpec"

  # ---------------------------------------------------------------------------
  # Events
  # ---------------------------------------------------------------------------
  /v1/my/events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List events (paginated)
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 100 }
        - name: event
          in: query
          schema: { type: string }
          description: Filter by event type
        - name: contact_id
          in: query
          schema: { type: integer }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          schema: { type: string, format: date-time }
        - name: resource_uid
          in: query
          schema: { type: string }
        - name: resource_name
          in: query
          schema: { type: string }
        - name: test
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: Paginated events
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Event" }

    post:
      tags: [Events]
      operationId: createEvent
      summary: Track a contact event
      description: |
        Requires an idempotency key via the `Idempotency-Key` header or
        `idempotency_key` body param. Duplicate events (same account +
        event type + idempotency key) return the existing event.
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string, maxLength: 128 }
          description: Idempotency key (alternative to body param)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event]
              properties:
                event:
                  type: string
                  pattern: "^[a-z][a-z0-9_]{0,63}$"
                  description: Event type name (lowercase, underscores)
                contact_id: { type: integer }
                contact_email:
                  type: string
                  format: email
                  description: Alternative to contact_id — resolves contact by email
                idempotency_key:
                  type: string
                  maxLength: 128
                  description: Idempotency key (alternative to header)
                amount: { type: number, format: double }
                resource_uid: { type: string }
                resource_name: { type: string }
                resource_url: { type: string, format: uri }
                test: { type: boolean, default: false }
                data:
                  type: object
                  additionalProperties: true
                  description: Custom event payload (max 32KB)
                utm_source: { type: string }
                utm_medium: { type: string }
                utm_term: { type: string }
                utm_content: { type: string }
                utm_campaign: { type: string }
      responses:
        "201":
          description: Event created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Event" }
        "200":
          description: Duplicate event (idempotent — returns existing)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Event" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/my/events/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Events]
      operationId: getEvent
      summary: Get an event
      responses:
        "200":
          description: Event
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Event" }
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Events]
      operationId: deleteEvent
      summary: Delete an event
      responses:
        "200":
          description: Deleted event
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Event" }

  # ---------------------------------------------------------------------------
  # Domains
  # ---------------------------------------------------------------------------
  /v1/my/domains:
    get:
      tags: [Domains]
      operationId: listDomains
      summary: List sending domains (paginated)
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: per
          in: query
          schema: { type: integer, default: 30, maximum: 100 }
      responses:
        "200":
          description: Paginated domains
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Domain" }

    post:
      tags: [Domains]
      operationId: createDomain
      summary: Add a sending domain
      description: |
        Initiates domain verification. Returns DNS records that must be
        added at your domain registrar before calling `POST /verify`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain:
                  type: object
                  required: [name]
                  properties:
                    name: { type: string, description: "e.g. send.example.com" }
      responses:
        "201":
          description: Domain registered with DNS records to configure
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/domains/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Domains]
      operationId: getDomain
      summary: Get a domain with DNS records and status
      responses:
        "200":
          description: Domain
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Domains]
      operationId: deleteDomain
      summary: Remove a sending domain
      responses:
        "204":
          description: Domain deleted

  /v1/my/domains/{id}/verify:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Domains]
      operationId: verifyDomain
      summary: Verify domain DNS records
      description: |
        Checks if the required DNS records have propagated. If verified,
        completes the `domain_verified` onboarding step and enables sending.
      responses:
        "200":
          description: Domain verification status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }

  /v1/my/domains/{id}/entri_session:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Domains]
      operationId: createEntriSession
      summary: Create an Entri bootstrap session for a pending domain
      description: |
        Returns a short-lived Entri JWT plus the DNS record payload needed to
        launch the Entri modal for a pending domain owned by the current brand.
      responses:
        "200":
          description: Entri bootstrap payload
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntriSessionResponse" }
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  # ---------------------------------------------------------------------------
  # Integrations
  # ---------------------------------------------------------------------------
  /v1/my/integrations:
    get:
      tags: [Integrations]
      operationId: listIntegrations
      summary: List integrations (paginated)
      parameters:
        - name: category
          in: query
          schema:
            type: string
            enum: [email, sms, crm]
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: per
          in: query
          schema: { type: integer, default: 30, maximum: 100 }
      responses:
        "200":
          description: Paginated integrations
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Integration" }
        "400":
          $ref: "#/components/responses/BadRequest"

    post:
      tags: [Integrations]
      operationId: createIntegration
      summary: Create or connect an email provider integration
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IntegrationWriteRequest" }
      responses:
        "201":
          description: Integration created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/integrations/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Integrations]
      operationId: getIntegration
      summary: Get an integration
      responses:
        "200":
          description: Integration
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Integrations]
      operationId: updateIntegration
      summary: Update an email provider integration
      description: |
        Updates credentials or operational fields for an existing email provider.
        The provider cannot be changed once the integration exists.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IntegrationWriteRequest" }
      responses:
        "200":
          description: Integration updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Integrations]
      operationId: deleteIntegration
      summary: Disconnect and delete an integration
      parameters:
        - name: confirm
          in: query
          required: true
          schema: { type: boolean }
          description: Must be true to confirm the destructive action.
      responses:
        "204":
          description: Integration deleted
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/my/integrations/{id}/test:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Integrations]
      operationId: testIntegration
      summary: Test integration connectivity
      responses:
        "200":
          description: Tested integration
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/my/integrations/{id}/primary:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    patch:
      tags: [Integrations]
      operationId: setPrimaryIntegration
      summary: Set an integration as the primary provider for its category
      responses:
        "200":
          description: Integration marked primary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/my/integrations/attio/connect:
    post:
      tags: [Integrations]
      operationId: connectAttioIntegration
      summary: Start Attio OAuth connect flow
      description: |
        Returns an Attio OAuth authorize URL for the authenticated account/brand.
        The response URL includes a signed state payload and the API callback URI.
      responses:
        "200":
          description: OAuth authorize URL
          content:
            application/json:
              schema:
                type: object
                required: [authorize_url]
                properties:
                  authorize_url:
                    type: string
                    format: uri
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/ValidationError"

  /integrations/attio/callback:
    get:
      tags: [Integrations]
      operationId: attioOauthCallback
      summary: Attio OAuth callback endpoint
      description: |
        Public callback endpoint used by Attio after user authorization.
        Verifies signed state, exchanges auth code, and finalizes Attio integration connection.
      parameters:
        - name: code
          in: query
          schema: { type: string }
        - name: state
          in: query
          schema: { type: string }
        - name: error
          in: query
          schema: { type: string }
        - name: error_description
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OAuth callback processed (HTML)
          content:
            text/html:
              schema:
                type: string
        "400":
          description: Invalid callback request
          content:
            text/html:
              schema:
                type: string
        "422":
          description: OAuth exchange or webhook provisioning failure
          content:
            text/html:
              schema:
                type: string

  # ---------------------------------------------------------------------------
  # Brands
  # ---------------------------------------------------------------------------
  /v1/my/brands:
    get:
      tags: [Brands]
      operationId: listBrands
      summary: List brands
      responses:
        "200":
          description: All brands for the account
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Brand" }

    post:
      tags: [Brands]
      operationId: createBrand
      summary: Create a brand
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                brand_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
                text_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
                bg_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
                font_heading: { type: string }
                font_body: { type: string }
                style_notes: { type: string }
                tone: { type: string }
                company_description: { type: string }
                industry: { type: string }
                physical_address: { type: string }
                company_name: { type: string }
                logo: { type: string, description: Signed blob ID or URL }
                email_from_name: { type: string }
                email_from_email: { type: string, format: email }
                email_reply_to: { type: string, format: email }
                test_email_recipients:
                  type: array
                  items: { type: string, format: email }
                  maxItems: 5
                example_copy:
                  type: array
                  items: { type: string }
                links:
                  type: array
                  items:
                    type: object
                    properties:
                      url: { type: string, format: uri }
                      icon: { type: string }
                      title: { type: string }
                default_header: { type: object, additionalProperties: true }
                default_footer: { type: object, additionalProperties: true }
                default_theme: { type: object, additionalProperties: true }
      responses:
        "201":
          description: Brand created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Brand" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/brands/{sid}:
    parameters:
      - $ref: "#/components/parameters/BrandSid"
    get:
      tags: [Brands]
      operationId: getBrand
      summary: Get a brand
      responses:
        "200":
          description: Brand
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Brand" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Brands]
      operationId: updateBrand
      summary: Update a brand
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                brand_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
                text_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
                bg_color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
                font_heading: { type: string }
                font_body: { type: string }
                style_notes: { type: string }
                tone: { type: string }
                company_description: { type: string }
                industry: { type: string }
                physical_address: { type: string }
                company_name: { type: string }
                logo: { type: string, description: Signed blob ID or URL }
                email_from_name: { type: string }
                email_from_email: { type: string, format: email }
                email_reply_to: { type: string, format: email }
                test_email_recipients:
                  type: array
                  items: { type: string, format: email }
                  maxItems: 5
                example_copy:
                  type: array
                  items: { type: string }
                links:
                  type: array
                  items:
                    type: object
                    properties:
                      url: { type: string, format: uri }
                      icon: { type: string }
                      title: { type: string }
                default_header: { type: object, additionalProperties: true }
                default_footer: { type: object, additionalProperties: true }
                default_theme: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Updated brand
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Brand" }
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      tags: [Brands]
      operationId: deleteBrand
      summary: Delete a brand
      responses:
        "200":
          description: Deleted brand
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Brand" }

  /v1/my/brands/{sid}/scrape:
    parameters:
      - $ref: "#/components/parameters/BrandSid"
    post:
      tags: [Brands]
      operationId: scrapeBrand
      summary: Auto-detect brand from website
      description: |
        Enqueues a background job that scrapes the URL for brand colors,
        fonts, logo, and tone. Returns 202 immediately. Poll
        `GET /v1/my/brands/{sid}` for results.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
      responses:
        "202":
          description: Scrape job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: scraping }
                  url: { type: string, format: uri }
        "422":
          description: Invalid URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }

  /v1/my/brands/{sid}/onboarding:
    parameters:
      - $ref: "#/components/parameters/BrandSid"
    get:
      tags: [Brands]
      operationId: getBrandOnboarding
      summary: Get brand onboarding state
      responses:
        "200":
          description: Onboarding state for the brand
          content:
            application/json:
              schema:
                type: object
                properties:
                  steps:
                    type: object
                    description: "Current state of each onboarding step"
                  progress:
                    type: object
                    properties:
                      completed: { type: integer }
                      total: { type: integer }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/my/brands/{sid}/onboarding/steps:
    parameters:
      - $ref: "#/components/parameters/BrandSid"
    post:
      tags: [Brands]
      operationId: completeBrandOnboardingStep
      summary: Complete a brand onboarding step
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [step]
              properties:
                step:
                  type: string
                  enum: [brand_setup, domain_verified, first_contact, first_send]
      responses:
        "200":
          description: Updated onboarding state
          content:
            application/json:
              schema:
                type: object
                properties:
                  steps:
                    type: object
                  progress:
                    type: object
                    properties:
                      completed: { type: integer }
                      total: { type: integer }
        "422":
          $ref: "#/components/responses/ValidationError"

  /v1/my/brands/{sid}/setup_center:
    parameters:
      - $ref: "#/components/parameters/BrandSid"
    get:
      tags: [Brands]
      operationId: getBrandSetupCenter
      summary: Get brand setup center state
      responses:
        "200":
          description: Setup center state for the brand
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetupCenterPayload" }
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Brands]
      operationId: updateBrandSetupCenter
      summary: Update brand setup center state
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                seen: { type: boolean }
                dismissed: { type: boolean }
                card:
                  type: string
                  enum: [brand_scan, dns, plan, connect_agent, import_subscribers, connect_transactional]
                metadata:
                  type: object
                  additionalProperties: true
              description: "Provide one of `seen`, `dismissed`, or `card`."
      responses:
        "200":
          description: Updated setup center state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetupCenterPayload" }
        "422":
          $ref: "#/components/responses/ValidationError"

  # ---------------------------------------------------------------------------
  # Messages
  # ---------------------------------------------------------------------------
  /v1/my/messages:
    post:
      tags: [Messages]
      operationId: createMessage
      summary: Send a transactional message
      description: |
        Send a transactional email or SMS to a single recipient immediately.
        No campaign, no audience, no approval required.
        Use for receipts, password resets, OTPs, order confirmations, and system notifications.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: Prevents duplicate sends on retry. Same key returns the original message.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, to]
              properties:
                channel:
                  type: string
                  enum: [email, sms]
                to:
                  type: string
                  description: Recipient email address or E.164 phone number
                subject:
                  type: string
                  description: Email subject line (required for email channel)
                body:
                  type: string
                  description: Message body. Required for SMS. Optional plain text for email.
                template_id:
                  type: integer
                  description: Load email design from an existing template (email only)
                data:
                  type: object
                  description: Merge variables
                  additionalProperties: true
                idempotency_key:
                  type: string
                  description: Idempotency key (alternative to header)
      responses:
        "201":
          description: Message created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "422":
          $ref: "#/components/responses/ValidationError"

    get:
      tags: [Messages]
      operationId: listMessages
      summary: List messages
      description: |
        Returns transactional messages by default. Use source_type=all to include
        flow/campaign-generated messages, or source_type=flow for flow-only.
      parameters:
        - name: source_type
          in: query
          schema: { type: string, enum: [all, flow] }
          description: "Filter by source. Omit for transactional only, 'all' for everything, 'flow' for flow/campaign messages."
        - name: channel
          in: query
          schema: { type: string, enum: [email, sms] }
        - name: status
          in: query
          schema: { type: string, enum: [queued, sent, failed] }
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated list of messages
          headers:
            X-Total-Count: { schema: { type: integer } }
            X-Total-Pages: { schema: { type: integer } }
            X-Page-Number: { schema: { type: integer } }
            X-Next-Page: { schema: { type: integer } }
            X-Prev-Page: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Message" }

  /v1/my/messages/{id}:
    get:
      tags: [Messages]
      operationId: getMessage
      summary: Get a transactional message
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Message
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "404":
          $ref: "#/components/responses/NotFound"

  # ---------------------------------------------------------------------------
  # Public
  # ---------------------------------------------------------------------------
  /v1/public/contacts:
    post:
      tags: [Public]
      operationId: createPublicContact
      summary: Add or attach a public signup contact
      description: |
        Idempotent public signup endpoint for landing pages and embeds.
        Authenticate with a brand public API key in the Bearer token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, list_id]
              properties:
                email: { type: string, format: email }
                list_id: { type: integer }
                source: { type: string, nullable: true }
      responses:
        "201":
          description: Contact accepted
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, const: true }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /v1/app:
    get:
      tags: [Public]
      operationId: getPlans
      summary: Get available plans
      security: []
      responses:
        "200":
          description: Active plans
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }

# =============================================================================
# COMPONENTS
# =============================================================================
components:
  # ---------------------------------------------------------------------------
  # Security Schemes
  # ---------------------------------------------------------------------------
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API key (`nskey_live_...`) or JWT token. API key is checked first;
        if not found, falls back to Devise JWT authentication.

        For multi-brand accounts, include the `X-Brand-SID` header to scope
        requests to a specific brand. If omitted, the account's default
        brand is used. JWT requests can also include `X-Account-ID` to select
        an accessible account.

  # ---------------------------------------------------------------------------
  # Parameters
  # ---------------------------------------------------------------------------
  parameters:
    ResourceId:
      name: id
      in: path
      required: true
      schema: { type: integer }
    BrandSid:
      name: sid
      in: path
      required: true
      schema: { type: string }
      description: Brand secure identifier
    PageParam:
      name: page
      in: query
      schema: { type: integer, default: 1 }
    LimitParam:
      name: limit
      in: query
      schema: { type: integer, default: 25, maximum: 100 }
    XBrandSID:
      name: X-Brand-SID
      in: header
      required: false
      schema: { type: string }
      description: |
        Optional brand context header. Pass the brand's `sid` to scope the
        request to a specific brand. If omitted, the account's default brand
        is used. Required for accounts with multiple brands.
    XAccountID:
      name: X-Account-ID
      in: header
      required: false
      schema: { type: integer }
      description: |
        Optional account context header for JWT-authenticated requests. Pass an
        accessible account id to switch the current account. API-key requests
        ignore this header.

  # ---------------------------------------------------------------------------
  # Responses
  # ---------------------------------------------------------------------------
  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    Unauthorized:
      description: Not authenticated
      headers:
        WWW-Authenticate:
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    Forbidden:
      description: Not authorized
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  validation_errors:
                    type: object
                    additionalProperties:
                      type: array
                      items: { type: string }

    TooManyRequests:
      description: Rate limit exceeded
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    ServiceUnavailable:
      description: Upstream service unavailable
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  # ---------------------------------------------------------------------------
  # Schemas
  # ---------------------------------------------------------------------------
  schemas:
    # -- Error --
    Error:
      type: object
      required: [code, message, error]
      properties:
        code: { type: integer }
        message: { type: string }
        error: { type: boolean, const: true }
        error_code:
          type: string
          nullable: true
          description: Optional machine-readable error reason.

    # -- User (default AMS) --
    User:
      type: object
      properties:
        id: { type: integer }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        email: { type: string, format: email }
        mobile: { type: string, nullable: true }
        country_code: { type: string, nullable: true }
        admin: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -- Account (AccountSerializer) --
    Account:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, nullable: true }
        avatar: { type: string, nullable: true, description: Signed blob ID }
        banner: { type: string, nullable: true, description: Signed blob ID }
        account_tier: { type: string, enum: [free, paid, trusted] }
        safe_mode_enabled: { type: boolean }
        billing:
          type: object
          properties:
            plan_name: { type: string }
            wallet_balance_cents: { type: integer }
            auto_topup: { type: boolean }
            resources:
              type: object
              properties:
                email: { type: object, properties: { used: { type: integer }, allowance: { type: integer }, remaining: { type: integer }, overage_rate: { type: number } } }
                sms: { type: object, properties: { used: { type: integer }, allowance: { type: integer }, remaining: { type: integer }, overage_rate: { type: number } } }
                ai: { type: object, properties: { used: { type: integer }, allowance: { type: integer }, remaining: { type: integer }, overage_rate: { type: number } } }
            lifetime:
              type: object
              properties:
                email_sent: { type: integer }
                sms_sent: { type: integer }
                ai_used: { type: integer }
        brands:
          type: array
          items: { $ref: "#/components/schemas/Brand" }
        team:
          type: object
          properties:
            seat_limit: { type: integer }
            seat_count: { type: integer }
            member_count: { type: integer }
            invite_count: { type: integer }
            current_role: { type: string, nullable: true }
            can_manage_team: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AccountTeam:
      type: object
      properties:
        account: { $ref: "#/components/schemas/Account" }
        memberships:
          type: array
          items: { $ref: "#/components/schemas/AccountMembership" }
        invites:
          type: array
          items: { $ref: "#/components/schemas/AccountInvite" }
        accessible_accounts:
          type: array
          items: { $ref: "#/components/schemas/Account" }

    AccountMembership:
      type: object
      properties:
        id: { type: integer }
        role: { type: string, enum: [member, admin, owner] }
        user_id: { type: integer }
        email: { type: string, format: email }
        name: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AccountInvite:
      type: object
      properties:
        id: { type: integer }
        account_id: { type: integer }
        email: { type: string, format: email }
        role: { type: string, enum: [member, admin] }
        token: { type: string }
        status: { type: string, enum: [pending, accepted, revoked, expired] }
        accepted_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OAuthPopupAccount:
      type: object
      required: [id, name, needs_subscribe]
      properties:
        id: { type: integer }
        name: { type: string }
        can_manage: { type: boolean }
        needs_subscribe: { type: boolean }

    OAuthPopupPlan:
      type: object
      required: [id, name, slug, base_price_cents]
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        base_price_cents: { type: integer }
        interval: { type: string, nullable: true }

    OAuthPopupContext:
      type: object
      required: [accounts, plans]
      properties:
        accounts:
          type: array
          items: { $ref: "#/components/schemas/OAuthPopupAccount" }
        plans:
          type: array
          items: { $ref: "#/components/schemas/OAuthPopupPlan" }
        stripe_publishable_key: { type: string, nullable: true }

    OAuthPopupSubscribeRequest:
      type: object
      required: [plan_id]
      properties:
        plan_id: { type: integer }
        account_id: { type: integer, nullable: true }
        stripe_token: { type: string, nullable: true }

    OAuthPopupSubscribeResponse:
      type: object
      required: [next_step]
      properties:
        next_step:
          type: string
          enum: [consent]

    OAuthLaunchRequest:
      type: object
      required: [provider]
      properties:
        provider:
          type: string
          enum: [google_oauth2, github]
        auth_intent:
          type: string
          enum: [app, agent]
          default: app
        auth_step:
          type: string
          enum: [login, signup]
          default: login
        resume_url:
          type: string
          format: uri
          nullable: true
          description: Required when `auth_intent=agent`; must point to the frontend `/oauth/connect` route.

    OAuthLaunchResponse:
      type: object
      required: [launch_url]
      properties:
        launch_url:
          type: string
          format: uri

    # -- Brand (BrandSerializer) --
    Brand:
      type: object
      properties:
        id: { type: integer }
        sid: { type: string, description: "Public secure identifier (non-sequential)", readOnly: true }
        account_id: { type: integer }
        brand_color: { type: string, nullable: true }
        text_color: { type: string, nullable: true }
        bg_color: { type: string, nullable: true }
        font_heading: { type: string, nullable: true }
        font_body: { type: string, nullable: true }
        style_notes: { type: string, nullable: true }
        tone: { type: string, nullable: true }
        company_description: { type: string, nullable: true }
        industry: { type: string, nullable: true }
        example_copy:
          type: array
          items: { type: string }
          nullable: true
        default_header: { type: object, nullable: true }
        default_footer: { type: object, nullable: true }
        default_theme: { type: object, nullable: true }
        physical_address: { type: string, nullable: true }
        company_name: { type: string, nullable: true }
        source_url: { type: string, nullable: true }
        last_scraped_at: { type: string, format: date-time, nullable: true }
        links:
          type: array
          items:
            type: object
            properties:
              url: { type: string }
              icon: { type: string }
              title: { type: string }
          nullable: true
        logo: { type: string, nullable: true, description: Logo URL }
        complete: { type: boolean, description: "True when brand_color and company_name are set" }
        email_from_name: { type: string, nullable: true }
        email_from_email: { type: string, nullable: true }
        email_reply_to: { type: string, nullable: true }
        test_email_recipients:
          type: array
          items: { type: string, format: email }
        onboarding_state:
          type: object
          additionalProperties: true
          description: "JSONB — keys are step names, values are completion metadata"
        onboarding:
          type: object
          properties:
            steps:
              type: object
              description: "Current state of each onboarding step"
            progress:
              type: object
              properties:
                completed: { type: integer }
                total: { type: integer }
        domain_verified: { type: boolean }
        can_send: { type: boolean }
        subscribed_contacts_count: { type: integer, description: "Count of subscribed contacts in this brand." }
        using_sandbox: { type: boolean }
        sandbox_email: { type: string, nullable: true }
        sandbox_monthly_cap: { type: integer }
        sandbox_sends_remaining: { type: integer }
        capabilities:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SetupCenterPayload:
      type: object
      properties:
        cards:
          type: array
          items: { $ref: "#/components/schemas/SetupCenterCard" }
        sections:
          type: object
          properties:
            required:
              type: array
              items: { type: string }
            recommended:
              type: array
              items: { type: string }
        progress:
          type: object
          properties:
            completed: { type: integer }
            total: { type: integer }
        seen: { type: boolean }
        dismissed: { type: boolean }
        complete: { type: boolean }

    SetupCenterCard:
      type: object
      properties:
        id:
          type: string
          enum: [brand_scan, dns, plan, connect_agent, import_subscribers, connect_transactional]
        complete: { type: boolean }
        acknowledged: { type: boolean }
        acknowledged_at: { type: string, format: date-time, nullable: true }

    # -- Contact (ContactSerializer) --
    Contact:
      type: object
      properties:
        id: { type: integer }
        brand_id: { type: integer, nullable: true }
        uuid: { type: string, format: uuid }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        source: { type: string, nullable: true }
        country_code: { type: string, nullable: true }
        data:
          type: object
          additionalProperties: true
          description: |
            Custom key-value data. The reserved key `tags` holds an array of
            string labels used for segmentation and targeting.
          example:
            tags: ["vip", "newsletter"]
            plan: "pro"
        subscribed_phone: { type: boolean }
        subscribed_email: { type: boolean }
        verification_status: { type: string, enum: [verified, suppressed, unverified] }
        enrichment_status: { type: string, enum: [enriched, not_enriched] }
        list_ids:
          type: array
          items: { type: integer }
        last_interacted_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        channels:
          type: array
          items: { $ref: "#/components/schemas/ContactChannel" }

    # -- ContactChannel (default AMS) --
    ContactChannel:
      type: object
      properties:
        id: { type: integer }
        contact_id: { type: integer }
        kind: { type: string, enum: [email, phone] }
        value: { type: string }
        subscribed: { type: boolean }
        verified: { type: boolean }
        opt_in_at: { type: string, format: date-time, nullable: true }
        opt_out_at: { type: string, format: date-time, nullable: true }
        sent_count: { type: integer }
        fail_count: { type: integer }
        data: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -- ContactList (default AMS) --
    ContactList:
      type: object
      properties:
        id: { type: integer }
        account_id: { type: integer }
        brand_id: { type: integer, nullable: true }
        name: { type: string }
        contacts_count: { type: integer }
        segment_id: { type: integer, nullable: true }
        stale: { type: boolean }
        last_populated_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ListDeleteWarning:
      type: object
      properties:
        campaign_names:
          type: array
          items: { type: string }
        flow_names:
          type: array
          items: { type: string }

    BulkListContactsRequest:
      type: object
      required: [action, emails]
      properties:
        action:
          type: string
          enum: [add, remove]
          description: Add existing contacts to the list or remove them from it.
        emails:
          type: array
          minItems: 1
          maxItems: 500
          items: { type: string, format: email }

    BulkListContactsResponse:
      type: object
      properties:
        action: { type: string, enum: [add, remove] }
        list_id: { type: integer }
        added: { type: integer, description: Contacts newly added for add actions }
        removed: { type: integer, description: Contacts removed for remove actions }
        already_in_list:
          type: array
          items: { type: string, format: email }
        not_in_list:
          type: array
          items: { type: string, format: email }
        not_found:
          type: array
          items: { type: string, format: email }
        invalid_emails:
          type: array
          items: { type: string }

    # -- Import (ImportSerializer) --
    Import:
      type: object
      properties:
        id: { type: integer }
        resource: { type: string, enum: [contacts] }
        parser: { type: string, enum: [default] }
        status: { type: string, enum: [pending, processing, failed, canceled, complete] }
        total_rows: { type: integer, nullable: true }
        success_rows: { type: integer, nullable: true }
        failed_rows: { type: integer, nullable: true }
        import_errors:
          type: array
          description: Row-level errors as `[line_number, message, source]`.
          items:
            type: array
            items:
              oneOf:
                - type: integer
                - type: string
        columns:
          type: object
          additionalProperties: true
          nullable: true
        options:
          type: object
          additionalProperties: true
          nullable: true
        started_at: { type: string, format: date-time, nullable: true }
        ended_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    # -- Segment (SegmentSerializer) --
    Segment:
      type: object
      properties:
        id: { type: integer }
        account_id: { type: integer }
        brand_id: { type: integer, nullable: true }
        name: { type: string }
        filters:
          $ref: "#/components/schemas/SegmentFilters"
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SegmentFilters:
      type: array
      items:
        type: object
        required: [name, predicate, value]
        properties:
          name:
            type: string
            description: |
              Filter name from flows.yml: contact_first_name, contact_last_name,
              contact_phone_number, contact_email, contact_country,
              contact_subscribed_phone, contact_subscribed_email,
              contact_created_at, contact_last_interacted_at,
              contact_source, contact_tag
          predicate:
            type: string
            description: |
              Ransack predicate: eq, not_eq, cont, not_cont, start, end,
              gt, lt, gteq, lteq, present, blank, true, false, in, not_in
          value:
            description: Filter value — string, number, boolean, or array for in/not_in

    # -- Campaign (CampaignSerializer) --
    Campaign:
      type: object
      properties:
        id: { type: integer }
        account_id: { type: integer }
        brand_id: { type: integer, nullable: true }
        status: { type: string, enum: [draft, active, paused, completed] }
        approval_state: { type: string }
        channel: { type: string, enum: [email, sms] }
        name: { type: string }
        data: { type: object, additionalProperties: true }
        scheduled_at: { type: string, format: date-time, nullable: true }
        sent_count: { type: integer }
        editable:
          type: boolean
          readOnly: true
          description: |
            True when the campaign's content/audience/template can be edited.
            False for live, paused, completed, cancelled, archived, and for
            scheduled campaigns within 5 minutes of their scheduled_at. When
            false, PUT update accepts only name-only payloads and status
            transitions (Resume / Cancel); anything else returns 422
            `campaign_locked`. Use POST /duplicate to fork into a new draft.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        trigger:
          $ref: "#/components/schemas/FlowTrigger"
        template:
          oneOf:
            - $ref: "#/components/schemas/Template"
            - type: "null"
        templates:
          type: array
          items: { $ref: "#/components/schemas/Template" }

    # -- Message (MessageSerializer) --
    Message:
      type: object
      properties:
        id: { type: integer }
        channel: { type: string, enum: [email, sms] }
        to: { type: string }
        subject: { type: string, nullable: true }
        status: { type: string, enum: [queued, sent, failed] }
        provider_id: { type: string, nullable: true }
        flow_id: { type: integer, nullable: true, description: "Source flow ID (null for transactional)" }
        source_type: { type: string, nullable: true, enum: [campaign, flow], description: "campaign, flow, or null (transactional)" }
        source_name: { type: string, nullable: true, description: "Name of source campaign or flow" }
        sent_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    # -- Template (TemplateSerializer) --
    Template:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, nullable: true }
        flow_id: { type: integer, nullable: true }
        action_id: { type: integer, nullable: true }
        subject: { type: string, nullable: true }
        body: { type: string, nullable: true }
        preheader: { type: string, nullable: true }
        from_name: { type: string, nullable: true }
        from_email: { type: string, nullable: true }
        reply_to: { type: string, nullable: true }
        design:
          oneOf:
            - $ref: "#/components/schemas/EmailDesign"
            - type: "null"
        variables: { type: object, additionalProperties: true }
        generation_id: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -- EmailStarter (config-based starter design) --
    EmailStarter:
      type: object
      properties:
        id: { type: string }
        category: { type: string }
        name: { type: string }
        description: { type: string }
        icon: { type: string }
        suggested_goals:
          type: array
          items: { type: string }
        design:
          $ref: "#/components/schemas/EmailDesign"
        preview_html: { type: string, nullable: true }

    # -- TemplateSummary (TemplateListSerializer — index only) --
    TemplateSummary:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, nullable: true }
        subject: { type: string, nullable: true }
        preheader: { type: string, nullable: true }
        flow_id: { type: integer, nullable: true }
        section_count: { type: integer }
        section_types:
          type: array
          items: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -- Flow (FlowSerializer) --
    Flow:
      type: object
      properties:
        id: { type: integer }
        account_id: { type: integer }
        brand_id: { type: integer, nullable: true }
        status: { type: string, enum: [draft, active, paused, archived] }
        approval_state: { type: string }
        name: { type: string }
        goal: { type: string, nullable: true }
        trigger:
          $ref: "#/components/schemas/FlowTriggerOutput"
        steps:
          type: array
          items:
            $ref: "#/components/schemas/FlowStepOutput"
        sent_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        templates:
          type: array
          items: { $ref: "#/components/schemas/Template" }

    # -- FlowTrigger (campaign trigger — via Flow::TriggerSerializer) --
    FlowTrigger:
      type: object
      properties:
        id: { type: integer }
        flow_id: { type: integer }
        event: { type: string }
        audience_type:
          type: string
          nullable: true
          enum: [lists, segment, all_contacts]
          description: "Explicit campaign audience target; null means no audience selected."
        segment_id: { type: integer, nullable: true }
        contact_list_id: { type: integer, nullable: true, deprecated: true, description: "Deprecated — use contact_list_ids" }
        contact_list_ids:
          type: array
          items: { type: integer }
          description: "Contact list IDs targeted by this trigger"
        data: { type: object, additionalProperties: true, nullable: true }
        triggered_count: { type: integer }
        last_triggered_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -- Flow trigger/step input shapes (for create/update) --
    FlowTriggerInput:
      type: object
      properties:
        event:
          type: string
          description: |
            Built-in: contact_add, keyword, message, list_add, list_remove,
            product_view, checkout, cart_add, cart_remove, cart_abandoned,
            browse_abandoned. Custom: any lowercase alphanumeric with underscores.
        audience_type:
          type: string
          nullable: true
          enum: [lists, segment, all_contacts]
          description: "Explicit campaign audience target; null means no audience selected."
        segment_id: { type: integer, nullable: true }
        contact_list_id: { type: integer, nullable: true, deprecated: true, description: "Deprecated — use contact_list_ids" }
        contact_list_ids:
          type: array
          items: { type: integer }
          description: "Contact list IDs to target"
        data: { type: object, additionalProperties: true }

    FlowTriggerOutput:
      description: Trigger as returned by Flow::Format.dump
      type: object
      properties:
        event: { type: string }
        segment_id: { type: integer, nullable: true }
        contact_list_id: { type: integer, nullable: true }
        data: { type: object, additionalProperties: true, nullable: true }

    FlowStepInput:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [email, sms, wait, split, emit_event]
        subject: { type: string, description: "Email step: subject line" }
        body: { type: string, description: "SMS body or email plain text" }
        preheader: { type: string, description: "Email step: preheader" }
        from_name: { type: string }
        from_email: { type: string }
        reply_to: { type: string }
        design:
          $ref: "#/components/schemas/EmailDesign"
        duration: { type: integer, description: "Wait step: seconds" }
        filters:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              predicate: { type: string }
              value: {}
          description: "Split step: condition filters"
        "yes":
          type: array
          items: { $ref: "#/components/schemas/FlowStepInput" }
          description: "Split step: yes branch"
        "no":
          type: array
          items: { $ref: "#/components/schemas/FlowStepInput" }
          description: "Split step: no branch"
        event_name: { type: string, description: "Emit event step: event to fire" }
        forward_event_data: { type: boolean, default: false }
        event_data: { type: object, additionalProperties: true }
        transactional: { type: boolean, default: false }

    FlowStepOutput:
      description: Step as returned by Flow::Format.dump
      type: object
      properties:
        type: { type: string, enum: [email, sms, wait, split, emit_event] }
        subject: { type: string }
        body: { type: string }
        preheader: { type: string }
        duration: { type: integer }
        filters: { type: array, items: { type: object } }
        "yes": { type: array, items: { $ref: "#/components/schemas/FlowStepOutput" } }
        "no": { type: array, items: { $ref: "#/components/schemas/FlowStepOutput" } }
        event_name: { type: string }
        forward_event_data: { type: boolean }

    # -- Event (default AMS) --
    Event:
      type: object
      properties:
        id: { type: integer }
        account_id: { type: integer }
        contact_id: { type: integer }
        user_id: { type: integer }
        event: { type: string }
        amount: { type: number, format: double, nullable: true }
        data: { type: object, additionalProperties: true }
        idempotency_key: { type: string, nullable: true }
        resource_uid: { type: string, nullable: true }
        resource_name: { type: string, nullable: true }
        resource_url: { type: string, nullable: true }
        test: { type: boolean }
        generated: { type: boolean }
        chain_depth: { type: integer }
        ip: { type: string, nullable: true }
        user_agent: { type: string, nullable: true }
        browser: { type: string, nullable: true }
        os: { type: string, nullable: true }
        device_type: { type: string, nullable: true }
        referrer: { type: string, nullable: true }
        utm_source: { type: string, nullable: true }
        utm_medium: { type: string, nullable: true }
        utm_term: { type: string, nullable: true }
        utm_content: { type: string, nullable: true }
        utm_campaign: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -- Domain (DomainSerializer) --
    Domain:
      type: object
      properties:
        id: { type: integer }
        brand_id: { type: integer, nullable: true }
        name: { type: string }
        provider: { type: string, default: ses }
        integration_id: { type: integer, nullable: true }
        status: { type: string, enum: [pending, verified, failed] }
        dns_records:
          type: array
          nullable: true
          items:
            type: object
            properties:
              record_type: { type: string }
              name: { type: string }
              value: { type: string }
              priority: { type: string, nullable: true }
              valid: { type: string }
        dns_health:
          type: object
          additionalProperties: true
          nullable: true
        dns_setup_status:
          type: string
          enum: [unchecked, incomplete, ready, verified]
        verified_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    EntriSessionResponse:
      type: object
      properties:
        domain:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            status: { type: string, enum: [pending, verified, failed] }
        entri:
          type: object
          properties:
            application_id: { type: string }
            token: { type: string }
            prefilled_domain: { type: string }
            user_id: { type: string }
            dns_records:
              type: array
              items:
                $ref: "#/components/schemas/EntriDnsRecord"

    EntriDnsRecord:
      type: object
      properties:
        type: { type: string }
        host: { type: string }
        value: { type: string }
        ttl: { type: integer }
        priority: { type: integer, nullable: true }

    # -- Integration (IntegrationSerializer) --
    Integration:
      type: object
      properties:
        id: { type: integer }
        provider: { type: string, enum: [mailgun, ses, postmark, resend, sendgrid, twilio, attio] }
        category: { type: string, enum: [email, sms, crm] }
        active: { type: boolean }
        primary: { type: boolean }
        status: { type: string, enum: [pending, connected, error] }
        connected_at: { type: string, format: date-time, nullable: true }
        last_tested_at: { type: string, format: date-time, nullable: true }
        error_message: { type: string, nullable: true }
        config_summary:
          type: object
          additionalProperties: true
        secret_hints:
          type: object
          additionalProperties:
            type: string
            nullable: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    IntegrationWriteRequest:
      type: object
      required: [integration]
      properties:
        integration:
          oneOf:
            - $ref: "#/components/schemas/MailgunIntegrationInput"
            - $ref: "#/components/schemas/SesIntegrationInput"
            - $ref: "#/components/schemas/PostmarkIntegrationInput"
            - $ref: "#/components/schemas/ResendIntegrationInput"
            - $ref: "#/components/schemas/SendgridIntegrationInput"

    MailgunIntegrationInput:
      type: object
      required: [provider, api_key, domain]
      properties:
        provider: { type: string, enum: [mailgun] }
        api_key: { type: string }
        domain: { type: string, description: Mailgun sending domain }
        region: { type: string, nullable: true, description: Optional Mailgun region hint }
        active: { type: boolean, default: true }

    SesIntegrationInput:
      type: object
      required: [provider, access_key_id, secret_access_key, region]
      properties:
        provider: { type: string, enum: [ses] }
        access_key_id: { type: string }
        secret_access_key: { type: string }
        region: { type: string, description: AWS SES region, e.g. us-east-1 }
        active: { type: boolean, default: true }

    PostmarkIntegrationInput:
      type: object
      required: [provider, server_token, account_token]
      properties:
        provider: { type: string, enum: [postmark] }
        server_token: { type: string, description: Postmark server token for sending email }
        account_token: { type: string, description: Postmark account token for domains API access }
        active: { type: boolean, default: true }

    ResendIntegrationInput:
      type: object
      required: [provider, api_key]
      properties:
        provider: { type: string, enum: [resend] }
        api_key: { type: string }
        active: { type: boolean, default: true }

    SendgridIntegrationInput:
      type: object
      required: [provider, api_key]
      properties:
        provider: { type: string, enum: [sendgrid] }
        api_key: { type: string }
        active: { type: boolean, default: true }

    # -- Plan (public) --
    Plan:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        active: { type: boolean }

    # -- Email Design --
    EmailDesign:
      type: object
      description: Email template design document
      properties:
        sections:
          type: array
          items:
            $ref: "#/components/schemas/EmailSection"
        theme:
          type: object
          description: Theme overrides merged on top of brand theme
          properties:
            brand_color: { type: string }
            bg_color: { type: string }
            text_color: { type: string }
            font_body: { type: string }
            font_heading: { type: string }
            logo_url: { type: string }

    EmailSection:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [header, hero, text, image, button, columns, product, social, divider, spacer, footer]
        props:
          type: object
          additionalProperties: true
          description: Section-specific properties (see email component spec)
        styles:
          type: object
          properties:
            background_color: { type: string }
            padding: { type: string }
            align: { type: string, enum: [left, center, right] }

    # -- Email Component Spec (from config/email_components.yml) --
    EmailComponentSpec:
      type: object
      description: |
        Full schema for email design sections. Each component has type,
        description, props (with types, required flags, defaults), and tips.
      properties:
        version: { type: integer }
        design_guidelines: { type: string }
        components:
          type: array
          items:
            type: object
            properties:
              type: { type: string }
              description: { type: string }
              tips: { type: array, items: { type: string } }
              props:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    type: { type: string }
                    required: { type: boolean }
                    default: {}
                    description: { type: string }
                    enum: { type: array, items: { type: string } }
        common_styles:
          type: object
        variables:
          type: array
          items: { type: string }
        theme_fields:
          type: array
          items: { type: string }

    # -- Flow Spec (from config/flows.yml) --
    FlowSpec:
      type: object
      description: |
        Schema for flow step types, trigger events, and segment filter names.
        Note: the raw response includes internal fields (icon, visible, inputs,
        outputs, alias) used by the flow editor UI — these can be ignored by
        API consumers.
      properties:
        filters:
          type: object
          additionalProperties:
            type: object
            properties:
              title: { type: string }
              summary: { type: string }
        triggers:
          type: array
          items:
            type: object
            properties:
              title: { type: string }
              event: { type: string }
        steps:
          type: array
          items:
            type: object
            properties:
              type: { type: string }
              title: { type: string }
              summary: { type: string }
              params:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    label: { type: string }
                    type: { type: string }
                    required: { type: boolean }
                    default: {}
                    description: { type: string }
