openapi: 3.1.0
info:
  title: Pollenate Cloud API
  description: |
    Feedback collection and analytics platform API.

    ## Authentication

    Most endpoints require authentication via JWT Bearer token or API key.

    - **JWT Token**: Obtained via OTP email verification, passed in `Authorization: Bearer <token>` header
    - **API Key**: For widget/external integrations, passed in `X-Pollenate-Key: <key>` header

    ## Organization Hierarchy

    - **Organizations**: Top-level accounts (companies)
    - **Brands**: Sub-organizations within an org (e.g., product lines)
    - **Inboxes**: Feedback collection endpoints within organizations (optionally filtered by brand)
    - **API Keys**: Can be org-wide or brand-scoped

  version: 1.1.0
  contact:
    name: Pollenate Support
    email: support@pollenate.dev
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: http://localhost:8787
    description: Local development
  - url: https://api.pollenate.dev
    description: Production

tags:
  - name: Auth
    description: Authentication and session management
  - name: Organizations
    description: Organization management
  - name: Brands
    description: Brand (sub-organization) management
  - name: Members
    description: Team member management
  - name: Invitations
    description: Team invitations (email-based)
  - name: Invite Links
    description: Shareable invite links for joining organizations
  - name: API Keys
    description: API key management
  - name: Inboxes
    description: Feedback inbox management
  - name: Feedback
    description: Feedback collection and retrieval
  - name: Analytics
    description: Impression tracking and analytics
  - name: Search
    description: Semantic search
  - name: Feedback Pages
    description: Feedback page form builder and public submissions
  - name: Themes
    description: Widget theme management
  - name: Automations
    description: |
      Notification rules that fire when events occur (e.g. `feedback.created`).
      Each rule has a trigger (event), optional filters, and an action: email,
      webhook, or Slack. Webhook deliveries POST a signed JSON payload to a
      public URL — see the [Automations & Webhooks guide](https://pollenate.dev/docs#automations).
  - name: Secrets
    description: |
      Encrypted, organization-scoped named secrets referenced by webhook
      automations as `{{secrets.NAME}}` in custom headers and body templates.
      Secret values are write-only and never returned by the API.

paths:
  # ==================== AUTH ====================
  /auth/otp/send:
    post:
      tags: [Auth]
      summary: Send OTP code
      description: Sends a 6-digit OTP code to the provided email address
      operationId: sendOtp
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  example: user@example.com
      responses:
        "200":
          description: OTP sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: OTP sent to user@example.com
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /auth/otp/verify:
    post:
      tags: [Auth]
      summary: Verify OTP code
      description: Verifies OTP code and returns JWT tokens
      operationId: verifyOtp
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email:
                  type: string
                  format: email
                code:
                  type: string
                  pattern: '^\d{6}$'
                  example: "123456"
      responses:
        "200":
          description: Authentication successful
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokens"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid or expired OTP
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /auth/refresh:
    post:
      tags: [Auth]
      summary: Refresh access token
      description: Exchange refresh token for new access token
      operationId: refreshToken
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken:
                  type: string
      responses:
        "200":
          description: Token refreshed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokens"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/logout:
    post:
      tags: [Auth]
      summary: Logout
      description: Revoke current session
      operationId: logout
      responses:
        "204":
          description: Logged out successfully

  /auth/me:
    get:
      tags: [Auth]
      summary: Get current user
      description: Returns the authenticated user's profile and organizations
      operationId: getCurrentUser
      responses:
        "200":
          description: User profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserWithOrganizations"
        "401":
          $ref: "#/components/responses/Unauthorized"
    patch:
      tags: [Auth]
      summary: Update current user profile
      description: Updates the authenticated user's profile (display name, avatar)
      operationId: updateProfile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: Display name
                avatarUrl:
                  type:
                    - string
                    - "null"
                  format: uri
                  description: Avatar image URL
      responses:
        "200":
          description: Profile updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ==================== ORGANIZATIONS ====================
  /organizations:
    get:
      tags: [Organizations]
      summary: List organizations
      description: Returns organizations the user is a member of
      operationId: listOrganizations
      responses:
        "200":
          description: List of organizations
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/OrganizationWithRole"
    post:
      tags: [Organizations]
      summary: Create organization
      description: Create a new organization (user becomes owner)
      operationId: createOrganization
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrganization"
      responses:
        "201":
          description: Organization created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: Organization slug already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /organizations/{orgId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Organizations]
      summary: Get organization
      operationId: getOrganization
      responses:
        "200":
          description: Organization details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrganizationDetail"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Organizations]
      summary: Update organization
      operationId: updateOrganization
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateOrganization"
      responses:
        "200":
          description: Organization updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        "403":
          $ref: "#/components/responses/Forbidden"
    delete:
      tags: [Organizations]
      summary: Delete organization
      description: Permanently delete organization and all data (owner only)
      operationId: deleteOrganization
      responses:
        "204":
          description: Organization deleted
        "403":
          $ref: "#/components/responses/Forbidden"

  # ==================== ORGANIZATION MEMBERS ====================
  /organizations/{orgId}/members:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Members]
      summary: List organization members
      operationId: listOrgMembers
      responses:
        "200":
          description: List of members
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Member"
  /organizations/{orgId}/members/{userId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/UserId"
    patch:
      tags: [Members]
      summary: Update member role
      operationId: updateOrgMember
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  $ref: "#/components/schemas/MemberRole"
      responses:
        "200":
          description: Member updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Member"
    delete:
      tags: [Members]
      summary: Remove member
      operationId: removeOrgMember
      responses:
        "204":
          description: Member removed

  # ==================== INVITATIONS ====================
  /organizations/{orgId}/invitations:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Invitations]
      summary: List pending invitations
      operationId: listInvitations
      responses:
        "200":
          description: List of invitations
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Invitation"
    post:
      tags: [Invitations]
      summary: Send invitation
      description: Send email invitation to join organization
      operationId: createInvitation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateInvitation"
      responses:
        "201":
          description: Invitation sent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invitation"
        "409":
          description: User already a member or has pending invitation

  /invitations/{token}:
    parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Invitations]
      summary: Get invitation details
      description: Get invitation info for acceptance page (public)
      operationId: getInvitation
      security: []
      responses:
        "200":
          description: Invitation details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvitationPublic"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Invitations]
      summary: Accept invitation
      description: Accept invitation and join organization
      operationId: acceptInvitation
      responses:
        "200":
          description: Invitation accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
  /organizations/{orgId}/invitations/{invitationId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - name: invitationId
        in: path
        required: true
        schema:
          type: string
        description: Invitation ID
    delete:
      tags: [Invitations]
      summary: Revoke invitation
      description: Cancel a pending invitation (admin only)
      operationId: revokeInvitation
      responses:
        "204":
          description: Invitation revoked
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ==================== INVITE LINKS ====================
  /organizations/{orgId}/invite-links:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Invite Links]
      summary: List invite links
      description: List all invite links for the organization. Requires `members.invite` permission.
      operationId: listInviteLinks
      responses:
        "200":
          description: List of invite links
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/InviteLink"
    post:
      tags: [Invite Links]
      summary: Create invite link
      description: |
        Create a shareable invite link for the organization. Unlike email invitations,
        anyone with the link can join (no specific email required).
        Requires `members.invite` permission.
      operationId: createInviteLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateInviteLink"
      responses:
        "201":
          description: Invite link created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InviteLink"
        "400":
          $ref: "#/components/responses/BadRequest"

  /organizations/{orgId}/invite-links/{linkId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - name: linkId
        in: path
        required: true
        schema:
          type: string
        description: Invite link ID
    patch:
      tags: [Invite Links]
      summary: Update invite link
      description: Update invite link settings. Requires `members.invite` permission.
      operationId: updateInviteLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                isActive:
                  type: boolean
                  description: Enable or disable the link
                label:
                  type: string
                  maxLength: 100
                  description: Admin-facing description
                role:
                  type: string
                  enum: [admin, member, billing, viewer]
                  description: Role assigned to new members
                maxUses:
                  type:
                    - integer
                    - "null"
                  minimum: 1
                  description: Maximum number of uses (null to remove limit)
                expiresAt:
                  type:
                    - string
                    - "null"
                  format: date-time
                  description: Expiration timestamp (null to remove expiry)
                brandIds:
                  type: array
                  items:
                    type: string
                  description: Brand IDs to grant access to on join
      responses:
        "200":
          description: Updated invite link
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InviteLink"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Invite Links]
      summary: Delete invite link
      description: Permanently delete an invite link. Requires `members.invite` permission.
      operationId: deleteInviteLink
      responses:
        "200":
          description: Link deleted
        "404":
          $ref: "#/components/responses/NotFound"

  /join/{code}:
    parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
        description: 8-character invite link code
    get:
      tags: [Invite Links]
      summary: Get invite link preview
      description: Public endpoint returning organization name, role, and link status. No authentication required.
      operationId: getInviteLinkPreview
      security: []
      responses:
        "200":
          description: Invite link details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InviteLinkPreview"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Invite Links]
      summary: Join via invite link
      description: |
        Accept an invite link and join the organization. Requires authentication.
        Returns the organization info for navigation.
        Sends an email notification to the admin who created the link.
      operationId: acceptInviteLink
      responses:
        "200":
          description: Joined organization successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        "403":
          description: Organization at member limit
        "409":
          description: User is already a member
        "410":
          description: Link expired, deactivated, or at max uses

  # ==================== API KEYS ====================
  /organizations/{orgId}/api-keys:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [API Keys]
      summary: List API keys
      operationId: listApiKeys
      parameters:
        - name: brandId
          in: query
          schema:
            type: string
          description: Filter by brand
      responses:
        "200":
          description: List of API keys (key values not included)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiKeyMeta"
    post:
      tags: [API Keys]
      summary: Create API key
      operationId: createApiKey
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateApiKey"
      responses:
        "201":
          description: API key created (includes full key - only returned once!)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyWithSecret"

  /organizations/{orgId}/api-keys/{keyId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - name: keyId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [API Keys]
      summary: Get API key metadata
      operationId: getApiKey
      responses:
        "200":
          description: API key metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyMeta"
    delete:
      tags: [API Keys]
      summary: Revoke API key
      operationId: revokeApiKey
      responses:
        "204":
          description: API key revoked

  # ==================== BRANDS ====================
  /organizations/{orgId}/brands:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Brands]
      summary: List brands
      operationId: listBrands
      responses:
        "200":
          description: List of brands
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Brand"
    post:
      tags: [Brands]
      summary: Create brand
      operationId: createBrand
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBrand"
      responses:
        "201":
          description: Brand created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Brand"

  /organizations/{orgId}/brands/{brandId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/BrandId"
    get:
      tags: [Brands]
      summary: Get brand
      operationId: getBrand
      responses:
        "200":
          description: Brand details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandDetail"
    patch:
      tags: [Brands]
      summary: Update brand
      operationId: updateBrand
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateBrand"
      responses:
        "200":
          description: Brand updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Brand"
    delete:
      tags: [Brands]
      summary: Delete brand
      operationId: deleteBrand
      responses:
        "204":
          description: Brand deleted

  # ==================== BRAND MEMBERS ====================
  /organizations/{orgId}/brands/{brandId}/members:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/BrandId"
    get:
      tags: [Members]
      summary: List brand members
      operationId: listBrandMembers
      responses:
        "200":
          description: List of brand members
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Member"
    post:
      tags: [Members]
      summary: Add brand member
      description: Add org member to brand team
      operationId: addBrandMember
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId:
                  type: string
                role:
                  type: string
                  enum: [admin, member]
                  default: member
      responses:
        "201":
          description: Member added to brand
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Member"

  # ==================== INBOXES ====================
  /organizations/{orgId}/inboxes:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Inboxes]
      summary: List inboxes
      description: List inboxes for the organization, optionally filtered by brand
      operationId: listInboxes
      parameters:
        - name: brandId
          in: query
          schema:
            type: string
          description: Filter inboxes by brand
      responses:
        "200":
          description: List of inboxes
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Inbox"
    post:
      tags: [Inboxes]
      summary: Create inbox
      operationId: createInbox
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateInbox"
      responses:
        "201":
          description: Inbox created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Inbox"

  /organizations/{orgId}/inboxes/{inboxId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/InboxId"
    get:
      tags: [Inboxes]
      summary: Get inbox
      operationId: getInbox
      responses:
        "200":
          description: Inbox details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboxDetail"
    patch:
      tags: [Inboxes]
      summary: Update inbox
      operationId: updateInbox
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateInbox"
      responses:
        "200":
          description: Inbox updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Inbox"
    delete:
      tags: [Inboxes]
      summary: Delete inbox
      operationId: deleteInbox
      responses:
        "204":
          description: Inbox deleted

  /organizations/{orgId}/inboxes/{inboxId}/regenerate-key:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/InboxId"
    post:
      tags: [Inboxes]
      summary: Regenerate inbox key
      description: Generate a new unique key for the inbox, invalidating the old one
      operationId: regenerateInboxKey
      responses:
        "200":
          description: New key generated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Inbox"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ==================== FEEDBACK ====================
  /collect:
    post:
      tags: [Feedback]
      summary: Submit feedback
      description: |
        Public endpoint for widget feedback submission. Requires API key with "collect" scope.

        This is the primary endpoint used by the embeddable widget to submit feedback.
        The API key authenticates the request and determines which organization the feedback belongs to.
      operationId: collectFeedback
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FeedbackSubmission"
      responses:
        "201":
          description: Feedback submitted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackEvent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid or missing API key
        "403":
          description: API key lacks required scope or inbox access
        "429":
          description: Rate limit exceeded for plan

  /organizations/{orgId}/feedback:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Feedback]
      summary: List feedback across organization
      operationId: listOrgFeedback
      parameters:
        - name: brandId
          in: query
          schema:
            type: string
        - name: inboxId
          in: query
          schema:
            type: string
        - name: type
          in: query
          schema:
            $ref: "#/components/schemas/FeedbackType"
        - name: since
          in: query
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated feedback list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackList"

  /organizations/{orgId}/feedback/{feedbackId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - name: feedbackId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Feedback]
      summary: Get feedback event
      operationId: getFeedback
      responses:
        "200":
          description: Feedback event details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackEvent"
    delete:
      tags: [Feedback]
      summary: Delete feedback
      operationId: deleteFeedback
      responses:
        "204":
          description: Feedback deleted

  # ==================== SEMANTIC SEARCH ====================
  /organizations/{orgId}/search:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [Search]
      summary: Semantic search
      description: Search feedback using natural language query
      operationId: semanticSearch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query:
                  type: string
                  minLength: 1
                  maxLength: 500
                  example: "users frustrated with login"
                brandId:
                  type: string
                inboxId:
                  type: string
                limit:
                  type: integer
                  minimum: 1
                  maximum: 100
                  default: 20
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  query:
                    type: string
                  mode:
                    type: string
                    enum: [semantic, text]
                    description: >-
                      How results were matched. `semantic` results are ranked
                      by vector similarity; `text` indicates the keyword
                      fallback was used.
                  results:
                    type: array
                    items:
                      $ref: "#/components/schemas/SearchResult"
                  total:
                    type: integer

  # ==================== THEMES ====================
  /themes/{themeId}:
    parameters:
      - name: themeId
        in: path
        required: true
        schema:
          type: string
        description: Theme ID
    get:
      tags: [Themes]
      summary: Get theme by ID
      description: Retrieve a public or system theme by its ID
      operationId: getTheme
      security: []
      responses:
        "200":
          description: Theme details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Theme"
        "404":
          $ref: "#/components/responses/NotFound"

  /themes/inbox/{inboxKey}:
    parameters:
      - name: inboxKey
        in: path
        required: true
        schema:
          type: string
        description: Inbox key
    get:
      tags: [Themes]
      summary: Get theme for inbox
      description: Public endpoint used by the widget to load the theme associated with an inbox
      operationId: getInboxTheme
      security: []
      responses:
        "200":
          description: Theme tokens for the inbox (returns default theme if none configured)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Theme"

  # ==================== IMPRESSIONS ====================
  /impressions:
    post:
      tags: [Analytics]
      summary: Track widget impression
      description: |
        Track when a widget loads on a page. This is called automatically by the widget
        on page load to track page views/impressions separate from feedback submissions.

        Impressions are used to calculate conversion rates (impressions → feedback).
      operationId: trackImpression
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImpressionSubmission"
      responses:
        "201":
          description: Impression tracked
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: Impression ID (used to link feedback to this impression)
                  success:
                    type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid or missing API key
        "429":
          description: Rate limit exceeded for plan

  # ==================== ANALYTICS ====================
  /analytics/summary:
    get:
      tags: [Analytics]
      summary: Get organization-wide analytics summary
      description: Returns impression and feedback analytics across all inboxes the user has access to
      operationId: getAnalyticsSummary
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
          description: Number of days to include in the summary
      responses:
        "200":
          description: Analytics summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalyticsSummary"

  /analytics/inboxes/{inboxId}/impressions:
    parameters:
      - $ref: "#/components/parameters/InboxId"
    get:
      tags: [Analytics]
      summary: Get inbox impression analytics
      description: Returns detailed impression analytics for a specific inbox
      operationId: getInboxImpressionAnalytics
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
        - name: groupBy
          in: query
          schema:
            type: string
            enum: [hour, day, week]
            default: day
          description: Time series grouping
      responses:
        "200":
          description: Inbox impression analytics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboxImpressionAnalytics"

  /analytics/inboxes/{inboxId}/impressions/context:
    parameters:
      - $ref: "#/components/parameters/InboxId"
    get:
      tags: [Analytics]
      summary: Get context field breakdown
      description: Analyze a specific field from the context JSON across impressions
      operationId: getContextBreakdown
      parameters:
        - name: field
          in: query
          required: true
          schema:
            type: string
          description: The context field to analyze (e.g., "page", "userId")
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
      responses:
        "200":
          description: Context field breakdown
          content:
            application/json:
              schema:
                type: object
                properties:
                  field:
                    type: string
                  breakdown:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                        impressions:
                          type: integer
                        with_feedback:
                          type: integer
                        conversion_rate:
                          type: number

  # ==================== FEEDBACK PAGES ====================
  /organizations/{orgId}/feedback-pages:
    get:
      tags: [Feedback Pages]
      summary: List feedback pages
      description: >-
        Returns all feedback pages for the organization. Requires an API key
        with the `list`, `read`, or `admin` scope.
      operationId: listFeedbackPages
      parameters:
        - $ref: "#/components/parameters/orgId"
      responses:
        "200":
          description: List of feedback pages
          content:
            application/json:
              schema:
                type: object
                properties:
                  pages:
                    type: array
                    items:
                      $ref: "#/components/schemas/FeedbackPage"
    post:
      tags: [Feedback Pages]
      summary: Create a feedback page
      description: Create a new feedback page with questions
      operationId: createFeedbackPage
      parameters:
        - $ref: "#/components/parameters/orgId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateFeedbackPage"
      responses:
        "201":
          description: Feedback page created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackPage"

  /organizations/{orgId}/feedback-pages/{pageId}:
    get:
      tags: [Feedback Pages]
      summary: Get a feedback page
      operationId: getFeedbackPage
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: pageId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Feedback page details with questions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackPage"
    put:
      tags: [Feedback Pages]
      summary: Update a feedback page
      operationId: updateFeedbackPage
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: pageId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateFeedbackPage"
      responses:
        "200":
          description: Feedback page updated
    delete:
      tags: [Feedback Pages]
      summary: Delete a feedback page
      operationId: deleteFeedbackPage
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: pageId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Feedback page deleted

  /organizations/{orgId}/feedback-pages/{pageId}/questions:
    post:
      tags: [Feedback Pages]
      summary: Add a question to a feedback page
      operationId: addFeedbackPageQuestion
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: pageId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateFeedbackPageQuestion"
      responses:
        "201":
          description: Question added

  /organizations/{orgId}/feedback-pages/{pageId}/generate:
    post:
      tags: [Feedback Pages]
      summary: AI-generate feedback page questions
      description: Uses AI to generate a feedback page title, description, and questions from a natural language prompt. Supports creating new pages and editing existing ones.
      operationId: aiGenerateFeedbackPage
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: pageId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt:
                  type: string
                  description: Natural language description of the form to generate
                  maxLength: 2000
                existingQuestions:
                  type: array
                  description: Current questions when editing an existing page
                  items:
                    type: object
                    properties:
                      type:
                        $ref: "#/components/schemas/FeedbackPageQuestionType"
                      prompt:
                        type: string
      responses:
        "200":
          description: AI-generated page content
          content:
            application/json:
              schema:
                type: object
                properties:
                  title:
                    type: string
                  description:
                    type: string
                  questions:
                    type: array
                    items:
                      $ref: "#/components/schemas/CreateFeedbackPageQuestion"

  /p/{shortCode}:
    get:
      tags: [Feedback Pages]
      summary: Get public feedback page
      description: Returns a public feedback page by short code for rendering to end users
      operationId: getPublicFeedbackPage
      security: []
      parameters:
        - name: shortCode
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Public feedback page with questions and brand settings

  /p/{shortCode}/submit:
    post:
      tags: [Feedback Pages]
      summary: Submit feedback page responses
      description: Submit answers to a public feedback page. Display-only question types (content_block, section_header) are automatically excluded.
      operationId: submitFeedbackPage
      security: []
      parameters:
        - name: shortCode
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [answers, turnstileToken]
              properties:
                answers:
                  type: array
                  items:
                    type: object
                    required: [questionId]
                    properties:
                      questionId:
                        type: string
                      score:
                        type: number
                        description: For rating types (thumbs, stars, csat, nps, emoji)
                      textValue:
                        type: string
                        description: For text, email, phone, number, date, dropdown, single/multi choice
                      multiValues:
                        type: array
                        items:
                          type: string
                        description: >-
                          For multi_choice (selected options) and ranking
                          (options in ranked order, first element = rank #1)
                contactName:
                  type: string
                contactEmail:
                  type: string
                  format: email
                turnstileToken:
                  type: string
                  description: Cloudflare Turnstile CAPTCHA token
      responses:
        "200":
          description: Submission recorded

  # ==================== AUTOMATIONS ====================
  /organizations/{orgId}/automations:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Automations]
      summary: List automation rules
      description: List notification rules for the organization, optionally filtered by inbox, scope, or page.
      operationId: listAutomations
      parameters:
        - name: inboxId
          in: query
          schema:
            type: string
          description: Filter rules by inbox
        - name: scope
          in: query
          schema:
            type: string
            enum: [inbox, organization]
          description: Filter rules by scope
        - name: pageId
          in: query
          schema:
            type: string
          description: Filter rules by feedback page (matches the pageId filter)
      responses:
        "200":
          description: List of automation rules
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AutomationRule"
    post:
      tags: [Automations]
      summary: Create automation rule
      description: |
        Create a notification rule. Webhook rules are assigned a randomly
        generated signing secret (returned as `secret`) used to compute the
        `X-Pollenate-Signature` HMAC-SHA256 header on each delivery.
      operationId: createAutomation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAutomation"
      responses:
        "201":
          description: Automation rule created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutomationRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

  /organizations/{orgId}/automations/logs/all:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Automations]
      summary: List automation logs (org-wide)
      description: Returns delivery logs across every automation rule in the organization.
      operationId: listAllAutomationLogs
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema:
            type: string
            enum: [success, failed, skipped]
        - name: event
          in: query
          schema:
            type: string
            enum: [feedback.created, page_response.created]
      responses:
        "200":
          description: Paginated automation logs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutomationLogPage"

  /organizations/{orgId}/automations/{automationId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/AutomationId"
    get:
      tags: [Automations]
      summary: Get automation rule
      operationId: getAutomation
      responses:
        "200":
          description: Automation rule
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutomationRule"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Automations]
      summary: Update automation rule
      operationId: updateAutomation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateAutomation"
      responses:
        "200":
          description: Automation rule updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutomationRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Automations]
      summary: Delete automation rule
      operationId: deleteAutomation
      responses:
        "200":
          description: Automation rule deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        "404":
          $ref: "#/components/responses/NotFound"

  /organizations/{orgId}/automations/{automationId}/test:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/AutomationId"
    post:
      tags: [Automations]
      summary: Test an automation rule
      description: |
        Delivers a sample payload to the rule's target so you can confirm the
        integration works. The sample event matches the rule's configured event
        (`feedback.created` or `page_response.created`).
      operationId: testAutomation
      responses:
        "200":
          description: Test delivery result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  type:
                    type: string
                    enum: [email, webhook]
                  statusCode:
                    type: integer
                    nullable: true
                    description: HTTP status returned by the webhook (null for email)
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          description: Delivery failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  error:
                    type: string

  /organizations/{orgId}/automations/{automationId}/logs:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/AutomationId"
    get:
      tags: [Automations]
      summary: List logs for a rule
      operationId: listAutomationLogs
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated delivery logs for the rule
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutomationLogPage"
        "404":
          $ref: "#/components/responses/NotFound"

  # ==================== SECRETS ====================
  /organizations/{orgId}/secrets:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [Secrets]
      summary: List secrets
      description: |
        Returns secret names and metadata only. Values are never returned —
        each secret exposes a masked `hint` (e.g. `••••cdef`).
      operationId: listSecrets
      responses:
        "200":
          description: List of secrets (metadata only)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Secret"
    post:
      tags: [Secrets]
      summary: Create secret
      description: |
        Store a new encrypted secret. The `value` is encrypted at rest and
        cannot be read back. Names must be unique within the organization.
      operationId: createSecret
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSecret"
      responses:
        "201":
          description: Secret created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Secret"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: A secret with this name already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /organizations/{orgId}/secrets/{secretId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - name: secretId
        in: path
        required: true
        schema:
          type: string
    patch:
      tags: [Secrets]
      summary: Update secret
      description: Rotate the value and/or edit the description of a secret.
      operationId: updateSecret
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateSecret"
      responses:
        "200":
          description: Secret updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Secret"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Secrets]
      summary: Delete secret
      operationId: deleteSecret
      responses:
        "200":
          description: Secret deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Partner API ──────────────────────────────────────────────────────────
  # Machine-to-machine access to feedback forms. Every endpoint accepts EITHER
  # an OAuth2 Bearer access token OR an X-Pollenate-Key API key.
  /v1/organizations/{orgId}/feedback-pages:
    get:
      tags: [Partner API]
      summary: List published feedback forms
      operationId: partnerListFeedbackPages
      security:
        - OAuth2: [forms:read]
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Paginated list of published forms
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { type: object }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
                  hasMore: { type: boolean }
  /v1/organizations/{orgId}/feedback-pages/{pageId}:
    get:
      tags: [Partner API]
      summary: Get a feedback form with its questions
      operationId: partnerGetFeedbackPage
      security:
        - OAuth2: [forms:read]
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: pageId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The form plus its ordered questions
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  title: { type: string }
                  slug: { type: string }
                  status: { type: string }
                  questions:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        type: { type: string }
                        prompt: { type: string }
                        required: { type: boolean }
                        sortOrder: { type: integer }
                        config: { type: object }
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/organizations/{orgId}/feedback-pages/{pageId}/responses:
    post:
      tags: [Partner API]
      summary: Submit a response on a respondent's behalf
      description: >
        Programmatically submit a full response with per-question answers. Unlike
        the public browser endpoint there is no Turnstile check, and the caller
        may attribute the response to an external respondent (phone number, call
        id, channel). Fires the `page_response.created` automation event.
      operationId: partnerSubmitResponse
      security:
        - OAuth2: [responses:write]
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: pageId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [answers]
              properties:
                answers:
                  type: array
                  items:
                    type: object
                    required: [questionId]
                    properties:
                      questionId: { type: string }
                      score: { type: number, nullable: true }
                      textValue: { type: string, nullable: true }
                      multiValues:
                        type: array
                        items: { type: string }
                        nullable: true
                respondentName: { type: string, nullable: true }
                respondentEmail: { type: string, nullable: true }
                source: { type: string, description: "e.g. bluehive-hum" }
                channel: { type: string, description: "e.g. voice | sms" }
                respondentPhone: { type: string, nullable: true }
                externalRespondentId: { type: string, nullable: true }
                externalMetadata: { type: object, nullable: true }
      responses:
        "201":
          description: Response recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  responseId: { type: string }
        "400":
          description: Validation error
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Form is not published

  /oauth/token:
    post:
      tags: [OAuth]
      summary: OAuth2 token endpoint
      description: >
        Exchange an authorization code (with PKCE `code_verifier`) for tokens, or
        rotate a refresh token. Accepts `application/x-www-form-urlencoded`.
        Authenticate the client with `client_id` + `client_secret`.
      operationId: oauthToken
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                grant_type:
                  type: string
                  enum: [authorization_code, refresh_token]
                code: { type: string }
                redirect_uri: { type: string }
                code_verifier: { type: string }
                refresh_token: { type: string }
                client_id: { type: string }
                client_secret: { type: string }
      responses:
        "200":
          description: Token response
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  token_type: { type: string, example: Bearer }
                  expires_in: { type: integer }
                  refresh_token: { type: string }
                  scope: { type: string }
                  organization_id: { type: string }
        "400":
          description: invalid_grant / invalid_request
        "401":
          description: invalid_client

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Pollenate-Key
    OAuth2:
      type: oauth2
      description: >
        OAuth2 authorization-code flow (with PKCE) for third-party apps. Tokens
        are sent as `Authorization: Bearer <access_token>` and are accepted on
        the `/v1` partner API in place of an API key.
      flows:
        authorizationCode:
          authorizationUrl: https://pollenate.dev/oauth/authorize
          tokenUrl: https://api.pollenate.dev/oauth/token
          refreshUrl: https://api.pollenate.dev/oauth/token
          scopes:
            forms:read: View feedback forms and their questions
            responses:write: Submit responses and answers to forms

  parameters:
    OrgId:
      name: orgId
      in: path
      required: true
      schema:
        type: string
      description: Organization ID
    BrandId:
      name: brandId
      in: path
      required: true
      schema:
        type: string
      description: Brand ID
    InboxId:
      name: inboxId
      in: path
      required: true
      schema:
        type: string
      description: Inbox ID
    UserId:
      name: userId
      in: path
      required: true
      schema:
        type: string
      description: User ID
    AutomationId:
      name: automationId
      in: path
      required: true
      schema:
        type: string
      description: Automation rule ID
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    Cursor:
      name: cursor
      in: query
      schema:
        type: string
      description: Pagination cursor

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: Insufficient permissions
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    TooManyRequests:
      description: Rate limit exceeded
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        code:
          type: string
        details:
          type: object

    # Auth
    AuthTokens:
      type: object
      properties:
        accessToken:
          type: string
        refreshToken:
          type: string
        expiresIn:
          type: integer
          description: Access token expiry in seconds
        user:
          $ref: "#/components/schemas/User"

    User:
      type: object
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        name:
          type: string
        avatarUrl:
          type: string
          format: uri
        createdAt:
          type: string
          format: date-time

    UserWithOrganizations:
      allOf:
        - $ref: "#/components/schemas/User"
        - type: object
          properties:
            organizations:
              type: array
              items:
                $ref: "#/components/schemas/OrganizationWithRole"

    # Organization
    Organization:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        slug:
          type: string
        logoUrl:
          type: string
          format: uri
        subscriptionStatus:
          type: string
          enum: [trial, active, past_due, canceled]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    OrganizationWithRole:
      allOf:
        - $ref: "#/components/schemas/Organization"
        - type: object
          properties:
            role:
              $ref: "#/components/schemas/MemberRole"

    OrganizationDetail:
      allOf:
        - $ref: "#/components/schemas/Organization"
        - type: object
          properties:
            memberCount:
              type: integer
            brandCount:
              type: integer
            settings:
              type: object

    CreateOrganization:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 2
          maxLength: 100
        slug:
          type: string
          pattern: "^[a-z0-9-]+$"
          minLength: 2
          maxLength: 50
          description: URL-friendly identifier (auto-generated if not provided)

    UpdateOrganization:
      type: object
      properties:
        name:
          type: string
        logoUrl:
          type: string
          format: uri
        settings:
          type: object

    # Members
    MemberRole:
      type: string
      enum: [owner, admin, member]

    Member:
      type: object
      properties:
        id:
          type: string
        userId:
          type: string
        email:
          type: string
        name:
          type: string
        avatarUrl:
          type: string
        role:
          $ref: "#/components/schemas/MemberRole"
        joinedAt:
          type: string
          format: date-time

    # Invitations
    Invitation:
      type: object
      properties:
        id:
          type: string
        email:
          type: string
        role:
          type: string
          enum: [admin, member]
        brandId:
          type:
            - string
            - "null"
        status:
          type: string
          enum: [pending, accepted, expired, revoked]
        invitedBy:
          type: object
          properties:
            id:
              type: string
            name:
              type: string
            email:
              type: string
        createdAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time

    InvitationPublic:
      type: object
      properties:
        organizationName:
          type: string
        brandName:
          type:
            - string
            - "null"
        invitedByName:
          type: string
        role:
          type: string
        expiresAt:
          type: string
          format: date-time

    CreateInvitation:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
        role:
          type: string
          enum: [admin, member]
          default: member
        brandId:
          type: string
          description: Optional brand to scope invitation to

    # Invite Links
    InviteLink:
      type: object
      properties:
        id:
          type: string
        code:
          type: string
          description: 8-character alphanumeric code used in the join URL
          example: Ab3xK7mN
        role:
          type: string
          enum: [admin, member, billing, viewer]
        label:
          type:
            - string
            - "null"
          description: Admin-facing description
        maxUses:
          type:
            - integer
            - "null"
          description: Maximum number of uses (null = unlimited)
        useCount:
          type: integer
          description: Number of times the link has been used
        expiresAt:
          type:
            - string
            - "null"
          format: date-time
          description: Expiration timestamp (null = never expires)
        isActive:
          type: boolean
        createdBy:
          type: object
          properties:
            id:
              type: string
            name:
              type:
                - string
                - "null"
            email:
              type: string
        brandIds:
          type: array
          items:
            type: string
          description: Brand IDs granted on join
        createdAt:
          type: string
          format: date-time

    InviteLinkPreview:
      type: object
      properties:
        organizationName:
          type: string
        organizationSlug:
          type: string
        role:
          type: string
        createdByName:
          type: string
        isActive:
          type: boolean
        isExpired:
          type: boolean
        isFull:
          type: boolean

    CreateInviteLink:
      type: object
      properties:
        role:
          type: string
          enum: [admin, member, billing, viewer]
          default: member
        label:
          type: string
          maxLength: 100
          description: Optional admin-facing description
        maxUses:
          type: integer
          minimum: 1
          description: Maximum number of uses (omit for unlimited)
        expiresIn:
          type: string
          enum: ["30m", "1h", "6h", "12h", "1d", "7d", "never"]
          description: Expiration preset
          default: "7d"
        brandIds:
          type: array
          items:
            type: string
          description: Brand IDs to grant access to on join

    # API Keys
    ApiKeyMeta:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        keyPrefix:
          type: string
          example: pk_live_ab
        brandId:
          type:
            - string
            - "null"
        scopes:
          type: array
          items:
            type: string
        lastUsedAt:
          type:
            - string
            - "null"
          format: date-time
        expiresAt:
          type:
            - string
            - "null"
          format: date-time
        createdAt:
          type: string
          format: date-time

    ApiKeyWithSecret:
      allOf:
        - $ref: "#/components/schemas/ApiKeyMeta"
        - type: object
          properties:
            key:
              type: string
              description: Full API key (only returned on creation!)
              example: pk_live_abc123xyz789...

    CreateApiKey:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 100
        brandId:
          type: string
          description: Scope key to specific brand (null = org-wide)
        scopes:
          type: array
          items:
            type: string
            enum: ["collect", "list", "read", "write", "admin"]
          minItems: 1
          default: ["collect"]
          description: |
            Permission scopes for the API key:
            - `collect`: Submit feedback via widget
            - `list`: List feedback pages (metadata only, no responses)
            - `read`: Read feedback, analytics, and responses
            - `write`: Create/update inboxes and settings
            - `admin`: Full access including team management
        expiresAt:
          type: string
          format: date-time
          description: Optional expiration date

    # Brands
    Brand:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        slug:
          type: string
        logoUrl:
          type: string
          format: uri
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    BrandDetail:
      allOf:
        - $ref: "#/components/schemas/Brand"
        - type: object
          properties:
            memberCount:
              type: integer
            inboxCount:
              type: integer
            settings:
              type: object

    CreateBrand:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 2
          maxLength: 100
        slug:
          type: string
          pattern: "^[a-z0-9-]+$"
        logoUrl:
          type: string
          format: uri

    UpdateBrand:
      type: object
      properties:
        name:
          type: string
        logoUrl:
          type: string
          format: uri
        settings:
          type: object

    # Inboxes
    Inbox:
      type: object
      properties:
        id:
          type: string
        key:
          type: string
        name:
          type: string
        description:
          type: string
        themeId:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    InboxDetail:
      allOf:
        - $ref: "#/components/schemas/Inbox"
        - type: object
          properties:
            feedbackCount:
              type: integer
            settings:
              type: object

    CreateInbox:
      type: object
      required: [name, key]
      properties:
        name:
          type: string
          minLength: 2
          maxLength: 100
        key:
          type: string
          pattern: "^[a-z0-9-_]+$"
          minLength: 2
          maxLength: 50
        description:
          type: string
          maxLength: 500
        themeId:
          type: string

    UpdateInbox:
      type: object
      properties:
        name:
          type: string
        description:
          type: string
        themeId:
          type: string
        settings:
          type: object

    # Feedback (widget types)
    FeedbackType:
      type: string
      description: Feedback type for widget-based collection
      enum: [thumbs, stars, csat, nps, emoji, text]

    # Feedback Page question types
    FeedbackPageQuestionType:
      type: string
      description: |
        Question type for feedback page forms. Includes all widget rating types plus
        structured input types and display-only layout controls.
      enum:
        - thumbs
        - stars
        - csat
        - nps
        - emoji
        - text
        - single_choice
        - multi_choice
        - content_block
        - section_header
        - email
        - phone
        - number
        - date
        - dropdown
        - ranking
        - file_upload
        - voice

    FeedbackPage:
      type: object
      properties:
        id:
          type: string
        title:
          type: string
        description:
          type: string
        brandId:
          type: string
        inboxId:
          type: string
        shortCode:
          type: string
        isActive:
          type: boolean
        displayMode:
          type: string
          enum: [all, one_at_a_time]
        introMarkdown:
          type: string
        questions:
          type: array
          items:
            $ref: "#/components/schemas/FeedbackPageQuestion"
        createdAt:
          type: string
          format: date-time

    FeedbackPageQuestion:
      type: object
      properties:
        id:
          type: string
        type:
          $ref: "#/components/schemas/FeedbackPageQuestionType"
        prompt:
          type: string
          description: The question text (or internal label for display-only types)
        description:
          type: string
          description: Optional help text shown below the question
        required:
          type: boolean
        sortOrder:
          type: integer
        config:
          $ref: "#/components/schemas/QuestionConfig"

    CreateFeedbackPage:
      type: object
      required: [title, brandId]
      properties:
        title:
          type: string
        description:
          type: string
        brandId:
          type: string
        inboxId:
          type: string
        displayMode:
          type: string
          enum: [all, one_at_a_time]
        introMarkdown:
          type: string
        questions:
          type: array
          items:
            $ref: "#/components/schemas/CreateFeedbackPageQuestion"

    UpdateFeedbackPage:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        inboxId:
          type: string
        isActive:
          type: boolean
        displayMode:
          type: string
          enum: [all, one_at_a_time]
        introMarkdown:
          type: string

    CreateFeedbackPageQuestion:
      type: object
      required: [type, prompt]
      properties:
        type:
          $ref: "#/components/schemas/FeedbackPageQuestionType"
        prompt:
          type: string
        description:
          type: string
        required:
          type: boolean
          default: false
        config:
          $ref: "#/components/schemas/QuestionConfig"

    QuestionConfig:
      type: object
      description: |
        Type-specific configuration. Fields vary by question type:
        - single_choice / multi_choice / dropdown / ranking: `options` (string array, required)
        - text: `multiline` (boolean), `validation` (email|phone|url|regex), `validationMessage`, `placeholder`
        - content_block: `markdown` (string, required)
        - email: `placeholder`
        - phone: `format` (us|international|any), `placeholder`
        - number: `min`, `max`, `step`, `unit`, `placeholder`
        - date: `includeTime` (boolean), `placeholder`
        - dropdown: `options` (string array, required), `placeholder`
        - ranking: `options` (string array, required) — respondents drag to reorder; submitted order is stored in the answer's `multiValues` (first = rank #1)
        - file_upload: `fileTypes` (any|images|documents|audio|video, default any), `maxFiles` (1–5, default 1), `maxSizeMb` (per-file size limit, default 10, max 25) — uploaded object keys are stored in the answer's `multiValues`
        - voice: `maxDurationSec` (max recording length in seconds, default 300) — the uploaded recording's object key is stored in the answer's `multiValues`
      properties:
        options:
          type: array
          items:
            type: string
          description: Options list for choice, dropdown, and ranking types
        multiline:
          type: boolean
          description: "Text type: true for textarea, false for single-line input"
        markdown:
          type: string
          description: "Content block: markdown content to render"
        fileTypes:
          type: string
          enum: [any, images, documents, audio, video]
          description: "File upload: which file categories are accepted (default any)"
        maxFiles:
          type: integer
          minimum: 1
          maximum: 5
          description: "File upload: maximum number of files (default 1)"
        maxSizeMb:
          type: integer
          description: "File upload: maximum size per file in MB (default 10, max 25)"
        maxDurationSec:
          type: integer
          description: "Voice: maximum recording length in seconds (default 300)"
        placeholder:
          type: string
          description: Placeholder text for input fields
        validation:
          type: string
          enum: [email, phone, url, regex]
          description: "Text type: validation rule"
        validationRegex:
          type: string
          description: "Custom regex when validation is 'regex'"
        validationMessage:
          type: string
          description: Custom error message for validation failures
        format:
          type: string
          enum: [us, international, any]
          description: "Phone type: number format"
        min:
          type: number
          description: "Number type: minimum value"
        max:
          type: number
          description: "Number type: maximum value"
        step:
          type: number
          description: "Number type: step increment"
        unit:
          type: string
          description: "Number type: unit label (e.g. 'kg', '$')"
        includeTime:
          type: boolean
          description: "Date type: include time picker"

    FeedbackSubmission:
      type: object
      required: [inboxKey, type]
      properties:
        inboxKey:
          type: string
        type:
          $ref: "#/components/schemas/FeedbackType"
        score:
          type: number
          minimum: 0
          maximum: 10
        comment:
          type: string
          maxLength: 2000
        context:
          type: object
          description: Arbitrary metadata (pageUrl, customerId, etc.)
          additionalProperties: true
        impressionId:
          type: string
          description: Optional ID of the tracked impression to link this feedback to
        sessionId:
          type: string
          description: Session identifier for correlating events
        pageUrl:
          type: string
          description: URL of the page where feedback was submitted

    ImpressionSubmission:
      type: object
      required: [inboxKey]
      properties:
        inboxKey:
          type: string
          description: The inbox key to track the impression for
        context:
          type: object
          description: Arbitrary metadata (page, userId, etc.)
          additionalProperties: true
        sessionId:
          type: string
          description: Session identifier for correlating events
        pageUrl:
          type: string
          description: URL of the page where widget loaded
        referrer:
          type: string
          description: Referrer URL
        userAgent:
          type: string
          description: Browser user agent
        externalUserId:
          type: string
          description: External user ID (also extracted from context.userId if not provided)

    AnalyticsSummary:
      type: object
      properties:
        totals:
          type: object
          properties:
            impressions:
              type: integer
            withFeedback:
              type: integer
            conversionRate:
              type: number
            uniqueUsers:
              type: integer
        byInbox:
          type: array
          items:
            type: object
            properties:
              inbox_id:
                type: string
              inbox_name:
                type: string
              brand_name:
                type: string
              total_impressions:
                type: integer
              impressions_with_feedback:
                type: integer
              conversion_rate:
                type: number
              unique_users:
                type: integer
        period:
          type: object
          properties:
            start:
              type: string
              format: date
            end:
              type: string
              format: date
            days:
              type: integer

    InboxImpressionAnalytics:
      type: object
      properties:
        summary:
          type: object
          properties:
            totalImpressions:
              type: integer
            impressionsWithFeedback:
              type: integer
            conversionRate:
              type: number
            uniqueUsers:
              type: integer
            uniqueSessions:
              type: integer
            period:
              type: object
              properties:
                start:
                  type: string
                  format: date
                end:
                  type: string
                  format: date
                days:
                  type: integer
        timeSeries:
          type: array
          items:
            type: object
            properties:
              period:
                type: string
              impressions:
                type: integer
              with_feedback:
                type: integer
              unique_users:
                type: integer
        topPages:
          type: array
          items:
            type: object
            properties:
              page_url:
                type: string
              impressions:
                type: integer
              with_feedback:
                type: integer
              conversion_rate:
                type: number
        topUsers:
          type: array
          items:
            type: object
            properties:
              external_user_id:
                type: string
              impressions:
                type: integer
              feedback_count:
                type: integer
              first_seen:
                type: string
                format: date-time
              last_seen:
                type: string
                format: date-time

    FeedbackEvent:
      type: object
      properties:
        id:
          type: string
        inboxId:
          type: string
        type:
          $ref: "#/components/schemas/FeedbackType"
        score:
          type: number
        comment:
          type: string
        context:
          type: object
        userAgent:
          type: string
        createdAt:
          type: string
          format: date-time

    FeedbackList:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/FeedbackEvent"
        cursor:
          type:
            - string
            - "null"
        hasMore:
          type: boolean

    # Search
    SearchResult:
      type: object
      properties:
        id:
          type: string
        inboxId:
          type: string
        inboxName:
          type: string
        brandName:
          type: string
        type:
          type: string
          enum: [thumbs, stars, csat, nps, emoji, text]
        score:
          type: number
          nullable: true
          description: The feedback event's own score (e.g. NPS value)
        comment:
          type: string
          nullable: true
        context:
          type: object
          nullable: true
        createdAt:
          type: string
          format: date-time
        relevance:
          type: number
          description: >-
            Cosine similarity to the query (0-1). Only present when
            `mode` is `semantic`.

    # Themes
    Theme:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        tokens:
          $ref: "#/components/schemas/ThemeTokens"
        isPublic:
          type: boolean
        createdAt:
          type: string
          format: date-time

    ThemeTokens:
      type: object
      properties:
        colorPrimary:
          type: string
          pattern: "^#[0-9a-fA-F]{6}$"
        colorAccent:
          type: string
        colorBackground:
          type: string
        colorText:
          type: string
        radius:
          type: string
        shadow:
          type: string
        fontFamily:
          type: string

    # Automations
    AutomationFilters:
      type: object
      description: |
        Conditions that must match for the rule to fire. An empty object matches
        everything. `type`/`source`/score filters apply to `feedback.created`;
        `pageId`/`hasEmail` apply to `page_response.created`.
      properties:
        type:
          oneOf:
            - type: string
            - type: array
              items:
                type: string
          description: "Feedback type(s): thumbs, stars, csat, nps, emoji, text"
        source:
          oneOf:
            - type: string
            - type: array
              items:
                type: string
          description: "Origin: widget, api, zapier, import, mcp"
        minScore:
          type: number
          description: Score greater than or equal to this value
        maxScore:
          type: number
          description: Score less than or equal to this value
        scoreEquals:
          type: number
        scoreAbove:
          type: number
          description: Score strictly greater than this value
        scoreBelow:
          type: number
          description: Score strictly less than this value
        hasComment:
          type: boolean
        sentiment:
          type: string
          enum: [positive, negative]
          description: Thumbs up (positive) or thumbs down (negative)
        npsCategory:
          type: string
          enum: [promoter, passive, detractor]
          description: "NPS bucket: promoter 9–10, passive 7–8, detractor 0–6"
        pageId:
          type: string
          description: Only responses from a specific feedback page
        hasEmail:
          type: boolean
          description: Only page responses that include a respondent email

    AutomationRule:
      type: object
      properties:
        id:
          type: string
        inboxId:
          type: string
          nullable: true
        organizationId:
          type: string
          nullable: true
        scope:
          type: string
          enum: [inbox, organization]
        inboxName:
          type: string
          nullable: true
        brandName:
          type: string
          nullable: true
        name:
          type: string
        type:
          type: string
          enum: [email, webhook, slack]
        target:
          type: string
          description: Email address(es) (comma-separated) or webhook URL
        events:
          type: array
          items:
            type: string
            enum: [feedback.created, page_response.created]
        filters:
          $ref: "#/components/schemas/AutomationFilters"
        method:
          type: string
          enum: [POST, PUT, PATCH, DELETE, GET]
          description: HTTP method for webhook rules
        headers:
          type: object
          additionalProperties:
            type: string
          description: |
            Custom request headers for webhook rules. Values may contain
            {{secrets.NAME}} and {{data.field}} placeholders resolved at
            delivery time.
        bodyTemplate:
          type: string
          nullable: true
          description: |
            Optional body template for webhook rules. When set, replaces the
            default JSON envelope. Supports {{...}} interpolation.
        secret:
          type: string
          nullable: true
          description: |
            HMAC-SHA256 signing secret for webhook rules. Used to verify the
            `X-Pollenate-Signature: sha256=<digest>` header on each delivery.
        isActive:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreateAutomation:
      type: object
      required: [name, type, target]
      properties:
        name:
          type: string
          maxLength: 200
        scope:
          type: string
          enum: [inbox, organization]
          default: inbox
        inboxId:
          type: string
          description: Required when scope is "inbox"
        organizationId:
          type: string
        type:
          type: string
          enum: [email, webhook, slack]
        target:
          type: string
          maxLength: 2000
          description: Email address(es) (comma-separated) or http(s) webhook URL
        events:
          type: array
          minItems: 1
          default: [feedback.created]
          items:
            type: string
            enum: [feedback.created, page_response.created]
          description: "page_response.created requires scope: organization"
        filters:
          $ref: "#/components/schemas/AutomationFilters"
        method:
          type: string
          enum: [POST, PUT, PATCH, DELETE, GET]
          default: POST
          description: HTTP method (webhook rules only)
        headers:
          type: object
          additionalProperties:
            type: string
          description: Custom request headers (webhook rules only), max 25
        bodyTemplate:
          type: string
          nullable: true
          maxLength: 20000
          description: Optional body template (webhook rules only)
        isActive:
          type: boolean
          default: true

    UpdateAutomation:
      type: object
      properties:
        name:
          type: string
          maxLength: 200
        type:
          type: string
          enum: [email, webhook, slack]
        target:
          type: string
          maxLength: 2000
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum: [feedback.created, page_response.created]
        filters:
          $ref: "#/components/schemas/AutomationFilters"
        method:
          type: string
          enum: [POST, PUT, PATCH, DELETE, GET]
        headers:
          type: object
          additionalProperties:
            type: string
        bodyTemplate:
          type: string
          nullable: true
          maxLength: 20000
        isActive:
          type: boolean

    AutomationLog:
      type: object
      properties:
        id:
          type: string
        ruleId:
          type: string
        eventType:
          type: string
          enum: [feedback.created, page_response.created]
        eventPayload:
          type: object
          nullable: true
          description: The data portion of the event payload
        status:
          type: string
          enum: [success, failed, skipped]
        statusCode:
          type: integer
          nullable: true
          description: HTTP status code returned by the webhook target
        errorMessage:
          type: string
          nullable: true
        durationMs:
          type: integer
          nullable: true
        createdAt:
          type: string
          format: date-time

    AutomationLogPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AutomationLog"
        cursor:
          type: string
          nullable: true
          description: Pass as the cursor query param to fetch the next page
        hasMore:
          type: boolean

    # Secrets
    Secret:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
          description: Identifier referenced as {{secrets.NAME}}
        hint:
          type: string
          nullable: true
          description: Masked preview (never the full value), e.g. ••••cdef
        description:
          type: string
          nullable: true
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreateSecret:
      type: object
      required: [name, value]
      properties:
        name:
          type: string
          maxLength: 100
          pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
          description: Letters, numbers, and underscores; must start with a letter or underscore
        value:
          type: string
          maxLength: 10000
          description: The secret value (encrypted at rest, never returned)
        description:
          type: string
          maxLength: 500

    UpdateSecret:
      type: object
      properties:
        value:
          type: string
          maxLength: 10000
          description: New value (rotates the secret)
        description:
          type: string
          maxLength: 500
          nullable: true

security:
  - BearerAuth: []
