openapi: 3.1.0
info:
  title: WACRM Core API
  version: "1.0.0"
  summary: Public API for the WACRM WhatsApp CRM (wacrm.ai)
  description: |
    The WACRM Core API lets the WACRM Chrome extension, the WACRM Wix app, and
    third-party/agent integrations read and write contacts, notes, tickets,
    labels, templates and WhatsApp messages for a WACRM Project, and call the
    metered AI endpoints (translate, detect-language, summarize, suggest-reply).

    "Project" in this API is the merchant's workspace (one Project = one
    WhatsApp number / one tenant). Internally a Project is backed by an
    internal store record — that implementation detail never appears in
    this API's responses.

    Base URL: `https://api.wacrm.ai/v1`. This document describes v1 only;
    breaking changes ship as `/v2`.
  contact:
    name: WACRM
    url: https://wacrm.ai
  license:
    name: Proprietary
    url: https://wacrm.ai/legal/terms
servers:
  - url: https://api.wacrm.ai/v1
    description: Production
  - url: https://api-sandbox.wacrm.ai/v1
    description: Sandbox (test API keys only, no wallet charges)

security:
  - ApiKeyAuth: []
  - BasicAuth: []
  - BearerAuth: []

tags:
  - name: Auth
    description: Extension device-code login
  - name: Projects
    description: Workspace / tenant resolution
  - name: Contacts
    description: WhatsApp end-customers (CRM contacts)
  - name: Labels
    description: Contact tags / segments
  - name: Notes
    description: Free-text notes attached to a contact
  - name: Tickets
    description: Lightweight support tickets attached to a contact
  - name: Templates
    description: Quick replies and WhatsApp Business message templates
  - name: Messages
    description: Conversation read + WhatsApp send
  - name: AI
    description: Metered AI services (translate, detect-language, summarize, suggest-reply)
  - name: Wallet
    description: Token balance and usage
  - name: Channels
    description: WhatsApp Embedded Signup connect flow (job P20-j3)
  - name: Conversations
    description: Workspace Inbox conversation list (job P20-j2b)
  - name: Products
    description: Synced products/SKUs, price and stock (read-only)
  - name: Orders
    description: Synced orders and order lines (read-only)
  - name: Export
    description: Read-only WorkChat-compat aliases (job P20-j6)
  - name: Webhooks
    description: Outbound webhook endpoint management (job P20-j6)
  - name: Broadcasts
    description: Segments, consent and template broadcasts (job P20-j5)
  - name: Work
    description: Tasks created from a conversation, bridged to the Work side (job P22-j1, M2c)
  - name: Agent
    description: Agent actions — propose, approve/reject, execute (job P20-j7)
  - name: Relay
    description: Inbound WhatsApp relay from the path-B forwarder (job P20), HMAC-authenticated, no API key
  - name: Bookings
    description: Booking services, derived availability, and appointment book/reschedule/cancel (job m4-booking-core, M4)
  - name: MCP
    description: MCP action metering — Pro/Agency gate, free allowance, $0.01/action reference charge (job P27-mcp-metering)
  - name: Audit
    description: Read side of the append-only activity/audit log (job P25-m5-audit-export-erase, R-CORE-08)
  - name: Client portal
    description: >-
      Mint/revoke a token-guarded client portal link for one contact, and the
      public token-guarded read it unlocks (job m4-ticket-client-portal, M4,
      R-WRK-03)

paths:
  /health:
    get:
      tags: [Projects]
      operationId: getHealth
      summary: Liveness/readiness check (public, no auth, no DB write)
      security: []
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                  version:
                    type: string
                  database:
                    type: string
                    enum: [ok]
        "503":
          description: Degraded (database unreachable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [degraded]
                  version:
                    type: string
                  database:
                    type: string
                    enum: [unreachable]

  /auth/extension/sessions:
    post:
      tags: [Auth]
      operationId: postAuthExtensionSessions
      summary: Start a device-code login session for the Chrome extension
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                client_name:
                  type: string
                  example: "WACRM Chrome Extension v2"
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExtensionSessionCreated"
        "429":
          $ref: "#/components/responses/RateLimited"

  /auth/extension/sessions/{session_id}:
    get:
      tags: [Auth]
      operationId: getAuthExtensionSessionsSession_id
      summary: Poll a device-code login session
      security: []
      parameters:
        - $ref: "#/components/parameters/SessionId"
      responses:
        "200":
          description: Current session status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExtensionSessionStatus"
        "404":
          $ref: "#/components/responses/NotFound"

  /auth/extension/sessions/{session_id}/approve:
    post:
      tags: [Auth]
      operationId: postAuthExtensionSessionsSession_idApprove
      summary: Approve a pending extension session (called from the logged-in Workspace tab)
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/SessionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_code]
              properties:
                user_code:
                  type: string
                  example: "WACR-7X2K"
      responses:
        "200":
          description: Session approved; the extension's next poll receives the scoped token
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [approved]
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Session already approved, expired, or user_code mismatch

  /crm-projects:
    get:
      tags: [Projects]
      operationId: getCrmProjects
      x-scopes: [projects:read]
      summary: List projects owned by the authenticated account
      security:
        - BasicAuth: []
        - BearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /crm-projects/current:
    get:
      tags: [Projects]
      operationId: getCrmProjectsCurrent
      x-scopes: [projects:read]
      summary: Resolve the project for the current credential (store API key, Wix app instance, etc.)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProject
      x-scopes: [projects:read]
      summary: Get a project by id
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/plan:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectPlan
      x-scopes: [projects:read]
      summary: Current plan, the full plan catalog, and seat/contact usage for a project (screen 21 "Plans & Upgrade")
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectPlanEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/storage-usage:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectStorageUsage
      x-scopes: [projects:read]
      summary: Storage quota usage for a project (job m5-storage-backup, M5 R-STO-02)
      description: >-
        Same owner-isolation rule as `GET /crm-projects/{project}/plan`: a project id
        this credential doesn't own 404s, never 403. Read-only, never blocks or
        mutates anything — the quota gate itself only runs at the media-download step.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StorageUsageEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/ai-pause:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectAiPause
      x-scopes: [projects:read]
      summary: Read the project's AI pause switch and auto-send opt-in
      description: >-
        A project with no stored setting reads as `paused: false, auto_send: false,
        auto_book: null`. Same owner-isolation rule as `GET /crm-projects/{project}/plan`:
        a project id this credential doesn't own 404s, never 403.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiPauseEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Projects]
      operationId: putCrmProjectsProjectAiPause
      x-scopes: [agent:approve]
      summary: Pause/resume AI auto-reply and set the auto-send opt-in
      description: >-
        While `paused` is true every agent action decision is `denied`
        (reason `ai_paused`) and an already-approved action that reaches
        execution fails with `error: ai_paused` — nothing is sent and nothing
        is deducted. `auto_send` (only effective while not paused) lets an
        in-window `send_message` execute without approval. `auto_book` is a
        separate opt-in for unattended booking execution; `null` (default)
        inherits `auto_send`. At least one field is required. Every change is
        audit-logged.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AiPauseUpdate"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiPauseEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /token-balance:
    get:
      tags: [Wallet]
      operationId: getTokenBalance
      x-scopes: [wallet:read]
      summary: Current token balance and this cycle's usage
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WalletEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /account/ai-usage:
    get:
      tags: [AI]
      operationId: getAccountAiUsage
      x-scopes: [ai:read]
      summary: Per-AI-action call count and net tokens deducted
      description: |
        Read from the existing `ai` wallet ledger (deducts net of refunds), job
        P27-ai-gateway-plan-gate. A project-scoped credential sees only its own
        project's usage; an account-scoped credential or a Workspace session sees
        every project its owner owns. Default range is the last 30 days.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      from:
                        type: string
                        format: date
                      to:
                        type: string
                        format: date
                      services:
                        type: array
                        items:
                          type: object
                          properties:
                            service_id:
                              type: string
                              example: translate
                            calls:
                              type: integer
                            tokens_deducted:
                              type: integer
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /commerce/products:
    get:
      tags: [Products]
      operationId: getCommerceProducts
      x-scopes: [products:read]
      summary: List products (search by name or SKU)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /commerce/products/{product}:
    get:
      tags: [Products]
      operationId: getCommerceProductsProduct
      x-scopes: [products:read]
      summary: Get a product, with its SKU/price/currency/stock and variants
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ProductId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /commerce/orders:
    get:
      tags: [Orders]
      operationId: getCommerceOrders
      x-scopes: [orders:read]
      summary: List orders (filter by contact phone/email, status, date)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: phone
          in: query
          description: Filter to orders whose buyer has this phone number
          schema:
            type: string
        - name: email
          in: query
          description: Filter to orders with this buyer email
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
        - name: date_from
          in: query
          description: Only orders created on/after this date (inclusive)
          schema:
            type: string
            format: date
        - name: date_to
          in: query
          description: Only orders created on/before this date (inclusive)
          schema:
            type: string
            format: date
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /commerce/orders/{order}:
    get:
      tags: [Orders]
      operationId: getCommerceOrdersOrder
      x-scopes: [orders:read]
      summary: Get an order, with its lines (quantity, resolved product, sku, price)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /commerce/orders/{order}/confirmation:
    get:
      tags: [Orders]
      operationId: getCommerceOrdersOrderConfirmation
      x-scopes: [orders:read]
      summary: Order-confirmation and COD two-way confirmation state for one order
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderConfirmationEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Order not found / not owned by this project, or no automation record exists for it
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /commerce/order-automation:
    get:
      tags: [Orders]
      operationId: getCommerceOrderAutomation
      x-scopes: [orders:read]
      summary: Read the project's order-automation settings (new order -> WhatsApp utility template, COD confirm)
      description: >-
        Per-project opt-in, default off. A project with no stored settings reads
        as `enabled: false`, both template names `null`, `language: en`.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderAutomationEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      tags: [Orders]
      operationId: putCommerceOrderAutomation
      x-scopes: [orders:write]
      summary: Update the project's order-automation settings
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderAutomationUpdate"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderAutomationEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: >-
            Validation failed, or `template_not_found` — a named template does
            not exist in this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /contacts:
    get:
      tags: [Contacts]
      operationId: getContacts
      x-scopes: [contacts:read]
      summary: List contacts
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: label
          in: query
          description: Filter to contacts carrying this label id
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Contacts]
      operationId: postContacts
      x-scopes: [contacts:write]
      summary: Create a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/PlanLimitReached"
        "403":
          $ref: "#/components/responses/Forbidden"

  /contacts/{contact}:
    get:
      tags: [Contacts]
      operationId: getContactsContact
      x-scopes: [contacts:read]
      summary: Get a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Contacts]
      operationId: patchContactsContact
      x-scopes: [contacts:write]
      summary: Update a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactUpdate"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Contacts]
      operationId: deleteContactsContact
      x-scopes: [contacts:write]
      summary: Erase a contact on request (hard delete)
      description: >-
        Erase-on-request (job P25-m5-audit-export-erase, R-CORE-08). Writes one
        `contact.erased` audit row (resource_id = the erased contact id), then
        hard-deletes the contact with its conversations, messages, attachment
        metadata, notes, tickets, label links and consent records, in one
        transaction. Only this project's contact is touched; a contact id this
        project doesn't own 404s and nothing is deleted.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      responses:
        "200":
          description: Erased
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [erased]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /audit-logs:
    get:
      tags: [Audit]
      operationId: getAuditLogs
      x-scopes: [audit:read]
      summary: List this project's activity/audit log (newest first)
      description: >-
        Read side of the append-only audit log (job P25-m5-audit-export-erase,
        R-CORE-08). Only rows of the calling project are returned.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: actor
          in: query
          description: Filter by actor id
          schema:
            type: string
        - name: action
          in: query
          description: Filter by action, e.g. `contact.created`
          schema:
            type: string
        - name: date_from
          in: query
          description: Inclusive start date (Y-m-d); any other format answers 400 invalid_input
          schema:
            type: string
            format: date
        - name: date_to
          in: query
          description: Inclusive end date (Y-m-d); any other format answers 400 invalid_input
          schema:
            type: string
            format: date
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        actor_type:
                          type: string
                        actor_id:
                          type: string
                          nullable: true
                        action:
                          type: string
                        resource_type:
                          type: string
                        resource_id:
                          type: string
                          nullable: true
                        metadata:
                          type: object
                        created_at:
                          type: string
                          format: date-time
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /crm-projects/{project}/export:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectExport
      x-scopes: [export:read]
      summary: Export this project's own data as NDJSON
      description: >-
        "Export my data" (job P25-m5-audit-export-erase, R-CORE-08). Streams one
        JSON object per line with a `type` of `contact`, `conversation`,
        `message` or `template`. No credentials, API keys or channel tokens are
        included. Writes a `project.exported` audit row. A project id this
        credential doesn't own, or a sibling project when called with a
        project key, answers 404, never 403.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: NDJSON stream
          content:
            application/x-ndjson:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /contacts/{contact}/labels:
    post:
      tags: [Labels]
      operationId: postContactsContactLabels
      x-scopes: [labels:write]
      summary: Attach a label to a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label_id]
              properties:
                label_id:
                  type: string
                  format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /contacts/{contact}/labels/{label}:
    delete:
      tags: [Labels]
      operationId: deleteContactsContactLabelsLabel
      x-scopes: [labels:write]
      summary: Remove a label from a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
        - $ref: "#/components/parameters/LabelId"
      responses:
        "200":
          description: OK
        "404":
          $ref: "#/components/responses/NotFound"

  /labels:
    get:
      tags: [Labels]
      operationId: getLabels
      x-scopes: [labels:read]
      summary: List labels
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LabelCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Labels]
      operationId: postLabels
      x-scopes: [labels:write]
      summary: Create a label
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 60
                color:
                  type: [string, "null"]
                  example: "#1E6FFF"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LabelEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"

  /labels/{label}:
    delete:
      tags: [Labels]
      operationId: deleteLabelsLabel
      x-scopes: [labels:write]
      summary: Delete a label
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/LabelId"
      responses:
        "200":
          description: OK
        "404":
          $ref: "#/components/responses/NotFound"

  /contacts/{contact}/notes:
    get:
      tags: [Notes]
      operationId: getContactsContactNotes
      x-scopes: [notes:read]
      summary: List notes on a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NoteCollection"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Notes]
      operationId: postContactsContactNotes
      x-scopes: [notes:write]
      summary: Add a note to a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                  maxLength: 5000
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NoteEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"

  /contacts/{contact}/tickets:
    get:
      tags: [Tickets]
      operationId: getContactsContactTickets
      x-scopes: [tickets:read]
      summary: List tickets on a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketCollection"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Tickets]
      operationId: postContactsContactTickets
      x-scopes: [tickets:write]
      summary: Create a ticket on a contact
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body]
              properties:
                subject:
                  type: string
                  maxLength: 255
                body:
                  type: string
                  maxLength: 5000
                priority:
                  type: string
                  enum: [low, normal, high]
                  default: normal
                type:
                  type: string
                  enum: [support, service_request]
                  default: support
                  description: job m4-ticket-client-portal, R-WRK-03.
                booking_id:
                  type: [string, "null"]
                  format: uuid
                  description: >-
                    Optionally ties this ticket to a booking on the same
                    project (job m4-ticket-client-portal). A booking id not
                    owned by this project answers 404.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"

  /tickets/{ticket}:
    patch:
      tags: [Tickets]
      operationId: patchTicketsTicket
      x-scopes: [tickets:write]
      summary: Update a ticket (e.g. change status)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/TicketId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: [open, pending, closed]
                priority:
                  type: string
                  enum: [low, normal, high]
                type:
                  type: string
                  enum: [support, service_request]
                booking_id:
                  type: [string, "null"]
                  format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TicketEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"

  /message-templates:
    get:
      tags: [Templates]
      operationId: getMessageTemplates
      x-scopes: [templates:read]
      summary: List quick replies / WhatsApp message templates
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, pending, approved, rejected]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Templates]
      operationId: postMessageTemplates
      x-scopes: [templates:write]
      summary: Create a template
      description: >-
        `target_languages` (job P23-translation-templates, R-TRN-03) creates
        one translated sibling row per target language in the same request,
        atomically — a translation or lint failure on any one of them rolls
        back the whole create, including the source row.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateVariantCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "422":
          $ref: "#/components/responses/TemplateLintFailed"

  /message-templates/{template}:
    patch:
      tags: [Templates]
      operationId: patchMessageTemplatesTemplate
      x-scopes: [templates:write]
      summary: Update a template
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/TemplateId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateCreate"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/TemplateLintFailed"

  /contacts/{contact}/messages:
    get:
      tags: [Messages]
      operationId: getContactsContactMessages
      x-scopes: [contacts:read]
      summary: Read a contact's WhatsApp conversation (watermark pagination)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
        - name: since_id
          in: query
          description: Return messages with id greater than this watermark
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 200
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageCollection"
        "404":
          $ref: "#/components/responses/NotFound"

  /conversations:
    get:
      tags: [Conversations]
      operationId: getConversations
      x-scopes: [contacts:read]
      summary: List conversations (Workspace Inbox)
      description: >-
        Newest activity first. There is no separate conversation-thread route
        — read a thread via the existing `GET /contacts/{contact}/messages`,
        using the `contact.id` carried by each item here.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Window"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConversationCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /messages:
    post:
      tags: [Messages]
      operationId: postMessages
      x-scopes: [messages:send]
      summary: Send a WhatsApp message (text or template)
      description: |
        Free-text (`type: text`) requires an open 24h customer-service window
        (the contact must have messaged in the last 24h), else `409 window_closed`.
        Template sends (`type: template`) work at any time but `template_id`
        must reference an `approved` template owned by this project.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            maxLength: 150
          description: Replays the original response verbatim for 24h on retry with the same key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageSend"
      responses:
        "200":
          description: Sent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageSendResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Free-text send attempted outside the 24h customer-service window
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Template send referenced a template that is not `approved` for this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /ai/translate:
    post:
      tags: [AI]
      operationId: postAiTranslate
      x-scopes: [ai:write]
      summary: Translate text
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text, target_lang]
              properties:
                text:
                  type: string
                  maxLength: 4096
                target_lang:
                  type: string
                  example: pt-BR
                source_lang:
                  type: [string, "null"]
                glossary_id:
                  type: [string, "null"]
                  format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiTranslateResult"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/detect-language:
    post:
      tags: [AI]
      operationId: postAiDetect-language
      x-scopes: [ai:read]
      summary: Detect the language of a text
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  maxLength: 4096
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiDetectLanguageResult"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/summarize:
    post:
      tags: [AI]
      operationId: postAiSummarize
      x-scopes: [ai:write]
      summary: Summarize a contact's conversation
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              properties:
                contact_id:
                  type: string
                  format: uuid
                message_limit:
                  type: integer
                  minimum: 1
                  maximum: 200
                  default: 50
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiSummaryResult"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/suggest-reply:
    post:
      tags: [AI]
      operationId: postAiSuggest-reply
      x-scopes: [ai:write]
      summary: Suggest reply drafts for a conversation (never sent automatically)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              properties:
                contact_id:
                  type: string
                  format: uuid
                max_suggestions:
                  type: integer
                  minimum: 1
                  maximum: 3
                  default: 1
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiSuggestReplyResult"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/product-answer:
    post:
      tags: [AI, Products]
      operationId: postAiProduct-answer
      x-scopes: [ai:write, products:read]
      summary: Draft a grounded answer to a product question (never an invented price)
      description: >-
        Searches this project's synced products/SKUs by the question's own
        words, then answers using ONLY that data — a price or currency the
        facts don't contain is replaced server-side with a safe template
        before the response is ever sent. Returns a draft only; it never
        sends a WhatsApp message.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question:
                  type: string
                  maxLength: 2000
                conversation_id:
                  type: [string, "null"]
                locale:
                  type: [string, "null"]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductAnswerResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          description: >-
            Missing `ai:write` or `products:read` scope (both are required), or — for an API-key
            caller only, a Workspace JWT/Basic session is unaffected — `ai_api_requires_pro_plan`
            (job P27-ai-gateway-plan-gate's `wacrm.ai.plan_gate`, added to this route by job
            P27-ai-services-batch-b's orchestrator review h03 follow-up): the project's plan is
            below Pro/Agency.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/classify-message:
    post:
      tags: [AI]
      operationId: postAiClassify-message
      x-scopes: [ai:write]
      summary: Classify a message's intent and sentiment (job P27-ai-services-batch-a)
      description: >-
        `intent` and `sentiment` are each checked against a fixed enum and `confidence` against
        `[0, 1]` server-side — a provider answer outside either is never passed through, it is
        rejected (`502 ai_output_rejected`) and the wallet deduction for the call is refunded.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  maxLength: 4096
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiClassifyMessageResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/AiPlanOrScopeForbidden"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/extract-entities:
    post:
      tags: [AI]
      operationId: postAiExtract-entities
      x-scopes: [ai:write]
      summary: Extract structured entities for a kind (order/address/booking/invoice) (job P27-ai-services-batch-a)
      description: >-
        Each `kind` has its own fixed schema, re-validated by rule server-side — the provider's raw
        JSON is never returned to the caller; a malformed or out-of-schema answer is rejected
        (`502 ai_output_rejected`) and the wallet deduction for the call is refunded.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, text]
              properties:
                kind:
                  type: string
                  enum: [order, address, booking, invoice]
                text:
                  type: string
                  maxLength: 4096
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiExtractEntitiesResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/AiPlanOrScopeForbidden"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /mcp/actions/authorize:
    post:
      tags: [MCP]
      operationId: postMcpActionsAuthorize
      summary: Authorize one MCP tool call before `apps/mcp` invokes the tool's own endpoint (job P27-mcp-metering)
      description: >-
        Called by the MCP server's own request pipeline BEFORE every tool call — a denied or
        unaffordable decision here means the tool's underlying endpoint is never reached. Behind
        the same Pro/Agency plan gate as `/v1/ai/*`, so a Starter-plan key never reaches this
        controller at all (`403 ai_api_requires_pro_plan`). The first `free_allowance` actions each
        calendar month per project are free; every action after that deducts
        `wallet.services.mcp.costs.action` tokens, keyed `mcp_action:{request_id}:deduct` so a
        retry with the same `request_id` replays the original decision and is never charged twice.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tool, request_id]
              properties:
                tool:
                  type: string
                  maxLength: 60
                  description: Name of the MCP tool about to be called (e.g. `send_template`)
                request_id:
                  type: string
                  maxLength: 150
                  description: Caller-or-server-generated id; reusing it makes a retry idempotent
      responses:
        "200":
          description: Allowed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/McpActionAuthorizeResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /ai/score-lead:
    post:
      tags: [AI]
      operationId: postAiScore-lead
      x-scopes: [ai:write]
      summary: Score how sales-qualified a contact is, with a suggested next step (job P27-ai-services-batch-b)
      description: >-
        Suggestion only — never automatically accepts or rejects a lead (roadmap M7 Meta-policy
        warning). `score` is checked against `[0, 100]` and `next_step` against a fixed enum
        server-side; a provider answer outside either is never passed through, it is rejected
        (`502 ai_output_rejected`) and the wallet deduction for the call is refunded.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              properties:
                contact_id:
                  type: string
                  format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiScoreLeadResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/AiPlanOrScopeForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/triage-alert:
    post:
      tags: [AI]
      operationId: postAiTriage-alert
      x-scopes: [ai:write]
      summary: Group and rank the priority of a batch of Care alerts (job P27-ai-services-batch-b)
      description: >-
        Suggestion only — never dismisses or auto-resolves an alert. `alerts` is a free-form list
        given in the request body (no `Alert` model exists for the Care module yet). Every group's
        `priority` is checked against a fixed enum and every `alert_ids` entry against the given
        alert ids (an unknown or doubly-assigned id is rejected) server-side; a malformed answer is
        rejected (`502 ai_output_rejected`) and the wallet deduction for the call is refunded.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [alerts]
              properties:
                alerts:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: object
                    required: [id, message]
                    properties:
                      id:
                        type: string
                        maxLength: 100
                      message:
                        type: string
                        maxLength: 2000
                      source:
                        type: [string, "null"]
                        maxLength: 100
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiTriageAlertResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/AiPlanOrScopeForbidden"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/suggest-next-action:
    post:
      tags: [AI]
      operationId: postAiSuggest-next-action
      x-scopes: [ai:write]
      summary: Suggest the next best action for a B2B sales deal (job P27-ai-services-batch-b)
      description: >-
        Suggestion only — never automatically sends a message or moves a pipeline stage (roadmap M7
        Meta-policy warning: "next-best-action chỉ là gợi ý không tự hành động"). `action` is
        checked against a fixed enum and `suggested_stage` (when present) against the pipeline-stage
        enum server-side; a provider answer outside either is never passed through, it is rejected
        (`502 ai_output_rejected`) and the wallet deduction for the call is refunded.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id, pipeline_stage]
              properties:
                contact_id:
                  type: string
                  format: uuid
                pipeline_stage:
                  type: string
                  enum: [new, qualified, quote_sent, negotiation, won, lost]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiSuggestNextActionResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/AiPlanOrScopeForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/booking-faq:
    post:
      tags: [AI, Bookings]
      operationId: postAiBooking-faq
      x-scopes: [ai:write]
      summary: Answer a booking FAQ (never an invented price or opening hour) (job m4-booking-ai-receptionist)
      description: >-
        Grounded ONLY in this project's own `wacrm_booking_services` (name,
        price, currency) and `wacrm_booking_staff` (working hours) rows — a
        price or opening hour the facts don't contain is replaced
        server-side with a safe template before the response is ever sent,
        the same `PriceGuard` re-check `POST /ai/product-answer` already
        uses.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question:
                  type: string
                  maxLength: 2000
                locale:
                  type: [string, "null"]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      answer: { type: string }
                      grounded: { type: boolean }
                      guard_triggered: { type: boolean }
                      provider: { type: string }
                      tokens_deducted: { type: integer }
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/booking-actions:
    post:
      tags: [AI, Bookings]
      operationId: postAiBooking-actions
      x-scopes: [ai:write, bookings:write]
      summary: Propose a book/reschedule/cancel action (job m4-booking-ai-receptionist)
      description: >-
        Evaluated by `App\Wacrm\Booking\BookingActionPolicy` (-> the shared
        `PolicyEngine`) as a `booking_action` risk-matrix entry — defaults to
        `needs_approval` for every project; auto-executes only once the
        project has opted into `auto_send` (`PUT .../ai-pause`). While the
        project's AI is paused, every proposal is denied with `409 ai_paused`
        regardless of that opt-in — zero booking rows either way. Execution
        (auto, or `POST .../{action}/approve`) goes through the same
        booking-core write path `POST /crm-projects/{project}/bookings` uses.
        `Idempotency-Key` is required — a retry with the same key replays the
        original proposal verbatim, never a second booking.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            maxLength: 150
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind:
                  type: string
                  enum: [book, reschedule, cancel]
                service_id: { type: [string, "null"], format: uuid }
                staff_id: { type: [string, "null"], format: uuid }
                contact_id: { type: [string, "null"], format: uuid }
                booking_id: { type: [string, "null"], format: uuid }
                start_at: { type: [string, "null"], format: date-time }
      responses:
        "201":
          description: Created (or replayed from an existing Idempotency-Key)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingAgentActionEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The project's AI is paused (`error.code=ai_paused`) — nothing is persisted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Unrecognised tool (policy_decision=denied) — not persisted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /ai/booking-actions/{action}/approve:
    post:
      tags: [AI, Bookings]
      operationId: postAiBooking-actionsActionApprove
      x-scopes: [ai:write, bookings:write]
      summary: Approve a proposed booking action, executing it through the booking core (job m4-booking-ai-receptionist)
      description: >-
        Idempotent: an already-terminal action (succeeded, failed, expired)
        is returned unchanged — a retried or double approve never
        re-executes and never writes a second booking.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/AgentActionId"
      responses:
        "200":
          description: OK — status is succeeded/failed once executed, or unchanged if already terminal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingAgentActionEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /glossary-terms:
    get:
      tags: [AI]
      operationId: getGlossaryTerms
      x-scopes: [ai:read]
      summary: List this project's translation glossary terms (ordered by target_lang, term)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GlossaryTermCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [AI]
      operationId: postGlossaryTerms
      x-scopes: [ai:write]
      summary: Add a forced replacement applied to `POST /ai/translate` for a language pair
      description: >-
        Every matching term is replaced literally (longest term first) before
        the text is translated. Adding or deleting a term invalidates any
        cached translation it could affect.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GlossaryTermCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GlossaryTermEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: "`duplicate_term` — this term already has a replacement for this language pair"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /glossary-terms/{term}:
    delete:
      tags: [AI]
      operationId: deleteGlossaryTermsTerm
      x-scopes: [ai:write]
      summary: Delete a glossary term
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GlossaryTermId"
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [deleted]
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /channels/whatsapp/connect-sessions:
    post:
      tags: [Channels]
      operationId: postChannelsWhatsappConnect-sessions
      x-scopes: [channels:write]
      summary: Start a WhatsApp Embedded Signup connect session
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                mode:
                  type: string
                  enum: [embedded_signup, coexistence]
                  default: embedded_signup
      responses:
        "201":
          description: Session started — pass app_id/config_id to FB.login() and echo state back on complete
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectSessionEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"

  /channels/whatsapp/connect-sessions/{session}/complete:
    post:
      tags: [Channels]
      operationId: postChannelsWhatsappConnect-sessionsSessionComplete
      x-scopes: [channels:write]
      summary: Complete a connect session (code -> token, debug_token-verified before trusting waba_id/phone_number_id)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ConnectSessionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, state, waba_id, phone_number_id]
              properties:
                code:
                  type: string
                state:
                  type: string
                waba_id:
                  type: string
                phone_number_id:
                  type: string
                business_id:
                  type: string
                display_phone:
                  type: string
      responses:
        "201":
          description: Connected — coex_status is now `connected`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChannelEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/PlanLimitReached"
        "403":
          description: waba_id/phone_number_id is on the protected-account guard — refused before any HTTP call
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: phone_number_id is already connected to a different project's channel (never reveals which one)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: state mismatch, or waba_id/phone_number_id not present in the exchanged token's own debug_token scopes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: The WhatsApp provider (Graph) returned an error during code exchange or verification
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /channels/{channel}/activate:
    post:
      tags: [Channels]
      operationId: postChannelsChannelActivate
      x-scopes: [channels:write]
      summary: Activate a connected number (subscribed_apps) — connected -> active
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ChannelId"
      responses:
        "200":
          description: Active
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChannelEnvelope"
        "403":
          description: This WABA/phone id is protected — refused before any HTTP call
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Channel is not in the `connected` state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: The subscribed_apps call to the WhatsApp provider failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /channels:
    get:
      tags: [Channels]
      operationId: getChannels
      x-scopes: [channels:read]
      summary: List this project's connected channels
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChannelCollection"

  /export/conversations:
    get:
      tags: [Export]
      operationId: getExportConversations
      x-scopes: [contacts:read]
      summary: WorkChat-compat alias of GET /conversations (read-only, same scope and shape)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Window"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          headers:
            Deprecation:
              schema:
                type: string
                example: "true"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConversationCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /export/contacts:
    get:
      tags: [Export]
      operationId: getExportContacts
      x-scopes: [contacts:read]
      summary: WorkChat-compat alias of GET /contacts (read-only, same scope and shape)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          headers:
            Deprecation:
              schema:
                type: string
                example: "true"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactCollection"

  /export/messages:
    get:
      tags: [Export]
      operationId: getExportMessages
      x-scopes: [contacts:read]
      summary: WorkChat-compat alias of GET /contacts/{contact}/messages (contact_id moved to query — see docs/api/README.md)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: contact_id
          in: query
          required: true
          schema:
            type: string
            format: uuid
        - name: since_id
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 200
      responses:
        "200":
          description: OK
          headers:
            Deprecation:
              schema:
                type: string
                example: "true"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageCollection"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhook-endpoints:
    get:
      tags: [Webhooks]
      operationId: getWebhookEndpoints
      x-scopes: [wa_webhooks:read]
      summary: List this project's outbound webhook endpoints (secret never included)
      description: >-
        Requires the `wa_webhooks:read` scope (opt-in, never granted by default; the legacy
        `webhooks:*` scopes do not grant access).
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointCollection"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Webhooks]
      operationId: postWebhookEndpoints
      x-scopes: [wa_webhooks:write]
      summary: Create a webhook endpoint — the secret is returned once, in this response only
      description: >-
        Requires the `wa_webhooks:write` scope (opt-in, never granted by default; the legacy
        `webhooks:*` scopes do not grant access).
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEndpointCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointCreatedEnvelope"
        "400":
          description: Validation failed, or the URL is not https / resolves to a non-public IP (SSRF guard)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"

  /webhook-endpoints/{endpoint}:
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpointsEndpoint
      x-scopes: [wa_webhooks:write]
      summary: Delete a webhook endpoint
      description: >-
        Requires the `wa_webhooks:write` scope (opt-in, never granted by default; the legacy
        `webhooks:*` scopes do not grant access).
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/WebhookEndpointId"
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [deleted]
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /segments:
    get:
      tags: [Broadcasts]
      operationId: getSegments
      x-scopes: [broadcasts:read]
      summary: List this project's saved audience segments
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SegmentCollection"
    post:
      tags: [Broadcasts]
      operationId: postSegments
      x-scopes: [broadcasts:write]
      summary: Create a saved audience segment
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                rules:
                  type: object
                  properties:
                    label_ids:
                      type: array
                      items:
                        type: string
                        format: uuid
                    has_open_window:
                      type: boolean
                    search:
                      type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SegmentEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"

  /contacts/{contact}/consent:
    get:
      tags: [Broadcasts]
      operationId: getContactsContactConsent
      x-scopes: [contacts:read]
      summary: Read a contact's current WhatsApp consent status
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      responses:
        "200":
          description: OK — `status` is `unknown` when no consent record exists yet
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Broadcasts]
      operationId: postContactsContactConsent
      x-scopes: [consent:write]
      summary: Record a contact's WhatsApp consent decision (upserts the current row)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [opted_in, opted_out]
                channel:
                  type: string
                  enum: [whatsapp]
                  default: whatsapp
                source:
                  type: string
      responses:
        "201":
          description: Recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"

  /broadcasts:
    post:
      tags: [Broadcasts]
      operationId: postBroadcasts
      x-scopes: [broadcasts:write]
      summary: Create a draft broadcast (template + segment, not started)
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [segment_id, template_id]
              properties:
                segment_id:
                  type: string
                  format: uuid
                template_id:
                  type: string
                  format: uuid
                pacing_per_minute:
                  type: integer
                  minimum: 1
                budget_cap:
                  type: integer
                  minimum: 1
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BroadcastEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "403":
          description: Template name not allowed on a protected WABA (TemplateNameGuard)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: template_id does not reference an approved template for this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /broadcasts/{broadcast}:
    get:
      tags: [Broadcasts]
      operationId: getBroadcastsBroadcast
      x-scopes: [broadcasts:read]
      summary: Read a broadcast's status and counts
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/BroadcastId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BroadcastEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /broadcasts/{broadcast}/start:
    post:
      tags: [Broadcasts]
      operationId: postBroadcastsBroadcastStart
      x-scopes: [broadcasts:write]
      summary: Start (or continue pumping) a broadcast — draft/paused/running -> running
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/BroadcastId"
      responses:
        "200":
          description: Running
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BroadcastEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Broadcast is `done` or `failed` (invalid_broadcast_state), or already reached its budget_cap (budget_exhausted)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /broadcasts/{broadcast}/pause:
    post:
      tags: [Broadcasts]
      operationId: postBroadcastsBroadcastPause
      x-scopes: [broadcasts:write]
      summary: Pause a running broadcast
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/BroadcastId"
      responses:
        "200":
          description: Paused
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BroadcastEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Broadcast is not `running`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /conversations/{conversation}/tasks:
    post:
      tags: [Work]
      operationId: postConversationTask
      x-scopes: [work:write]
      summary: Create a task from a conversation
      description: >-
        Writes the task and a `task.requested` domain event in one DB
        transaction; delivery to the Work side happens asynchronously (job
        P22-j1). `Idempotency-Key` is required — a retry with the same key
        returns the original task verbatim.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ConversationId"
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            maxLength: 150
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkTaskCreate"
      responses:
        "201":
          description: Created (or replayed from an existing Idempotency-Key)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkTaskEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    get:
      tags: [Work]
      operationId: getConversationTasks
      x-scopes: [work:read]
      summary: List tasks created from a conversation
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/ConversationId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkTaskCollection"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /work/callbacks:
    post:
      tags: [Work]
      operationId: postWorkCallbacks
      summary: Inbound task status update from the Work side
      description: >-
        No API key — authenticated by `X-WACRM-Work-Signature` HMAC over the
        raw body with a per-install secret, fail-closed when the secret is
        unset. Updates the WACRM task matching (`project`, `external_ref`) by `status`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkCallback"
      responses:
        "200":
          description: OK
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          description: No task with that project/external_ref pair (nothing changed)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing/unset secret or an invalid X-WACRM-Work-Signature
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agent-actions:
    get:
      tags: [Agent]
      operationId: getAgentActions
      x-scopes: [agent:propose]
      summary: List this project's agent actions
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/AgentActionStatus"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentActionCollection"
    post:
      tags: [Agent]
      operationId: postAgentActions
      x-scopes: [agent:propose]
      summary: Propose an agent action (auto-executes when the policy engine decides low risk)
      description: >-
        Evaluated by the PolicyEngine risk matrix (design §3.6): `low` risk
        auto-executes immediately through the same guarded services a human
        write uses; `medium`/`high` risk is created `proposed` and waits for
        `POST .../{action}/approve`; an unrecognised tool is `denied` (422)
        and never persisted. While the project's AI is paused
        (`GET/PUT .../ai-pause`), every tool is denied with `409 ai_paused`
        instead (job P23-autoreply-wire) — checked before the risk matrix, so
        it applies even to an otherwise-unrecognised tool name.
        `Idempotency-Key` is required — a retry with the
        same key replays the original proposal verbatim.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            maxLength: 150
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentActionCreate"
      responses:
        "201":
          description: Created (or replayed from an existing Idempotency-Key)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentActionEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The project's AI is paused (`error.code=ai_paused`) — nothing is persisted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Unrecognised tool (policy_decision=denied) — not persisted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agent-actions/{action}/approve:
    post:
      tags: [Agent]
      operationId: postAgentActionsActionApprove
      x-scopes: [agent:approve]
      summary: Approve a proposed action, executing it through the guarded services
      description: >-
        Approver identity is taken from the API key/session, never from the
        request body. Idempotent: an already-terminal action (succeeded,
        failed, rejected, expired) is returned unchanged — a retried or
        double approve never re-executes and never deducts the wallet twice.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/AgentActionId"
      responses:
        "200":
          description: OK — status is succeeded/failed once executed, or unchanged if already terminal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentActionEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /agent-actions/{action}/reject:
    post:
      tags: [Agent]
      operationId: postAgentActionsActionReject
      x-scopes: [agent:approve]
      summary: Reject a proposed action — it is never executed
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - $ref: "#/components/parameters/AgentActionId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentActionEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhooks/meta/relay:
    post:
      tags: [Relay]
      operationId: postWebhooksMetaRelay
      summary: Inbound WhatsApp traffic forwarded by the path-B relay (internal, not a customer-facing resource)
      description: >-
        Receiving end of the path-B relay (`docs/design/m2-whatsapp-module.md` §3.2): an existing
        webhook owned by another team forwards inbound WhatsApp traffic here, already filtered to
        allowed numbers. WACRM never calls Meta on this path. No API key, Basic, or Bearer
        credential — authenticated entirely by the `X-WACRM-Relay-*` HMAC headers described below,
        fail-closed when the shared secret is unset. See docs/api/README.md "Inbound relay (path B)"
        for the full header contract and routing rules.
      security: []
      parameters:
        - name: X-WACRM-Relay-Id
          in: header
          required: true
          schema:
            type: string
          description: Unique id (ULID) for this relay delivery — the replay-guard key.
        - name: X-WACRM-Relay-Ts
          in: header
          required: true
          schema:
            type: string
          description: Unix seconds the forwarder signed at.
        - name: X-WACRM-Relay-Hop
          in: header
          required: true
          schema:
            type: string
          description: Must be "1" — anything greater is rejected (loop guard).
        - name: X-WACRM-Relay-Sig
          in: header
          required: true
          schema:
            type: string
          description: "hex(HMAC-SHA256(secret, \"{ts}.{relay_id}.{sha256_hex(raw_body)}\"))"
        - name: X-WACRM-Relay-Split
          in: header
          required: false
          schema:
            type: string
          description: Present (any value) when the forwarder trimmed the payload to only WACRM-routed entries.
        - name: X-Hub-Signature-256
          in: header
          required: false
          schema:
            type: string
          description: Optional defense-in-depth re-check against the Meta app secret; ignored when X-WACRM-Relay-Split is present.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw Meta WhatsApp Cloud API webhook payload, forwarded unchanged.
      responses:
        "202":
          description: Accepted — processing is asynchronous
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: The X-WACRM-Relay-Id just processed
        "200":
          description: Already processed (X-WACRM-Relay-Id seen before within the replay TTL)
          content:
            application/json:
              schema:
                type: object
                properties:
                  duplicate:
                    type: boolean
                    enum: [true]
        "400":
          description: X-WACRM-Relay-Hop is greater than 1
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Bad/missing relay HMAC, stale timestamp, or a present-but-wrong X-Hub-Signature-256
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Source IP not in the relay's configured allowlist (only enforced when that env is set)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: The relay shared secret is unset — fails closed rather than accepting unsigned input
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/plan/checkout:
    post:
      tags: [Projects]
      operationId: postCrmProjectsProjectPlanCheckout
      x-scopes: [billing:write]
      summary: Start a PayPal recurring-subscription checkout for a WACRM plan (SANDBOX ONLY)
      description: >-
        Creates a PayPal SANDBOX subscription for one of the 4 `wacrm_plans`
        catalog keys and returns the buyer approval URL. Same owner-isolation
        rule as `GET /crm-projects/{project}/plan` (a project id this
        credential doesn't own 404s, never 403). A plan with no
        `paypal_plan_id` configured (`config/wacrm_plans.php`) answers `422
        plan_not_purchasable` rather than an invented PayPal plan id. The
        project's plan only changes once PayPal's
        `BILLING.SUBSCRIPTION.ACTIVATED` webhook arrives for the returned
        `subscription_id` — see docs/handoff/p23-billing-paypal-sandbox.md.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlanCheckoutRequest"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlanCheckoutEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Plan has no PayPal plan id configured — not purchasable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: >-
            Checkout unavailable — PayPal mode is not `sandbox`, or the
            sandbox gateway call failed (`error.code = checkout_unavailable`).
            Never a raw 500 and never the PayPal response body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/meta-pages:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectMetaPages
      x-scopes: [meta_dm:read]
      summary: List this project's connected Messenger/Instagram Pages
      description: >-
        Behind `WACRM_META_DM_ENABLED` (default off): `404 meta_dm_disabled`
        while the flag is off. Never includes a token. Same owner-isolation
        rule as `GET /crm-projects/{project}/plan`: a project id this
        credential doesn't own 404s, never 403.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetaPageListEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not owned by this credential, or the flag is off (`meta_dm_disabled`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      tags: [Projects]
      operationId: postCrmProjectsProjectMetaPages
      x-scopes: [meta_dm:write]
      summary: Connect a Facebook Page (Messenger) or linked Instagram Professional account
      description: >-
        Validates `page_access_token` with one Graph GET for the claimed
        `page_id`, stores the token encrypted (`wacrm_credentials`, never
        logged or returned), creates/updates `wacrm_channel_accounts`
        (`channel: messenger|instagram`) and the `wacrm_meta_pages` row, then
        subscribes the page to this app's webhook fields (a subscribe
        failure does not unwind the connect). `page_id` is global across
        Meta: a `page_id` already connected to a **different** project
        answers `409 page_already_connected` before any row is written,
        never naming the owner. No OAuth/Facebook-Login redirect here (needs
        Meta app review) — see docs/handoff/p23-meta-dm-b.md.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MetaPageCreate"
      responses:
        "201":
          description: Created (or updated, if this project already owns this page_id)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetaPageEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not owned by this credential, or the flag is off (`meta_dm_disabled`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: page_id already connected to a different project (`page_already_connected`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Token did not verify against the claimed page_id (`token_invalid`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/meta-pages/{metaPage}:
    delete:
      tags: [Projects]
      operationId: deleteCrmProjectsProjectMetaPagesMetaPage
      x-scopes: [meta_dm:write]
      summary: Disconnect a Messenger/Instagram Page
      description: >-
        Flips `status` to `disconnected` on the page and its channel account
        — never deletes the row, its conversations, or its messages. A
        `metaPage` id not owned by this project 404s, same as a foreign
        `{project}` id.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: metaPage
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetaPageEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/booking-services:
    get:
      tags: [Bookings]
      operationId: getCrmProjectsProjectBookingServices
      x-scopes: [bookings:read]
      summary: List this project's bookable services
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingServiceCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Bookings]
      operationId: postCrmProjectsProjectBookingServices
      x-scopes: [bookings:write]
      summary: Create a bookable service
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingServiceCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingServiceEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/booking-services/{service}:
    patch:
      tags: [Bookings]
      operationId: patchCrmProjectsProjectBookingService
      x-scopes: [bookings:write]
      summary: Update a bookable service
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/BookingServiceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingServiceUpdate"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingServiceEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No project with that id, or no service with that id on this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/availability:
    get:
      tags: [Bookings]
      operationId: getCrmProjectsProjectAvailability
      x-scopes: [bookings:read]
      summary: Derived free slots for a service on one calendar date
      description: >-
        No precomputed slots table — slots are sliced from the matching
        staff member(s)' `weekly_hours` (per docs/handoff/p24-m4-booking-core.md)
        minus existing non-cancelled bookings, computed at read time.
        `staff` narrows to one staff member; omitted, every active staff
        member on the project is considered.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: service
          in: query
          required: true
          schema:
            type: string
            format: uuid
        - name: date
          in: query
          required: true
          description: Calendar date, `YYYY-MM-DD`, interpreted in each candidate staff member's own timezone.
          schema:
            type: string
            format: date
        - $ref: "#/components/parameters/BookingStaffIdQuery"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvailabilitySlotCollection"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No project, service, or staff member with that id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/bookings:
    get:
      tags: [Bookings]
      operationId: getCrmProjectsProjectBookings
      x-scopes: [bookings:read]
      summary: List this project's bookings
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, confirmed, reminded, completed, no_show, cancelled]
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Bookings]
      operationId: postCrmProjectsProjectBookings
      x-scopes: [bookings:write]
      summary: Book a slot
      description: >-
        `Idempotency-Key` is required — a retry with the same key within 24h
        replays the original booking verbatim (same id), same contract as
        `POST /v1/messages`. An already-taken slot (same staff, or same
        staffless service, overlapping another active booking) answers
        `409 slot_taken`. A start/end range outside the chosen staff
        member's `weekly_hours` for that day answers `422 outside_staff_hours`.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            maxLength: 150
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingCreate"
      responses:
        "201":
          description: Created (or replayed from an existing Idempotency-Key)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No project, service, staff member, or contact with that id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: slot_taken — this slot already has an active booking
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: outside_staff_hours — outside the chosen staff member's working hours
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/bookings/{booking}:
    patch:
      tags: [Bookings]
      operationId: patchCrmProjectsProjectBooking
      x-scopes: [bookings:write]
      summary: Reschedule a booking (moves it, never creates a second row)
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/BookingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingReschedule"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No project with that id, or no booking with that id on this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: slot_taken — the new slot already has an active booking
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: outside_staff_hours — outside the chosen staff member's working hours
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/bookings/{booking}/cancel:
    post:
      tags: [Bookings]
      operationId: postCrmProjectsProjectBookingCancel
      x-scopes: [bookings:write]
      summary: Cancel a booking (sets status + cancelled_at, keeps the row)
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/BookingId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No project with that id, or no booking with that id on this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/contacts/{contact}/client-portal-links:
    post:
      tags: [Client portal]
      operationId: postCrmProjectsProjectContactsContactClientPortalLinks
      x-scopes: [client_portal:write]
      summary: Mint a client portal link for a contact
      description: >-
        Always creates a brand-new link; the raw `token` is returned exactly
        once here and never recoverable afterwards (only its hash is stored).
        `expires_in_days` omitted means the link never expires on its own —
        revoke it explicitly to stop it working.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                expires_in_days:
                  type: integer
                  minimum: 1
                  maximum: 365
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClientPortalLinkEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No project with that id, or no contact with that id on this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/client-portal-links/{link}/revoke:
    post:
      tags: [Client portal]
      operationId: postCrmProjectsProjectClientPortalLinkRevoke
      x-scopes: [client_portal:write]
      summary: Revoke a client portal link (one-way; mint a new one if needed)
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/ClientPortalLinkId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClientPortalLinkEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No project with that id, or no client portal link with that id on this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /client-portal/{token}:
    get:
      tags: [Client portal]
      operationId: getClientPortalToken
      summary: Public, token-guarded read of one contact's own tickets + bookings
      description: >-
        No API key, no Workspace session — the unguessable token in the path
        is its own credential. A missing, expired, or
        revoked token answers the exact same 404 as any other unknown token
        — never a distinct status. The payload never includes a wallet,
        plan, or billing field (docs/ux/wacrm-ux-spec.md:377).
      security: []
      parameters:
        - $ref: "#/components/parameters/ClientPortalToken"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClientPortalDataEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /public/store/{slug}/booking-services:
    get:
      tags: [Bookings]
      operationId: getPublicStoreSlugBookingServices
      summary: Public, token-free list of a project's bookable services (WACRM-hosted booking page)
      description: >-
        No API key, no Basic/Bearer credential — the project is resolved by
        its public `{slug}` (the same slug `/store/{slug}/portal` already
        uses), not a credential (job m4-public-booking-page). Only
        active/bookable services are listed; never wallet or plan data.
        `powered_by_badge` reflects the `WACRM_BOOKING_POWERED_BY_BADGE` flag
        (`config/wacrm.php` `booking.powered_by_badge`, default `false`).
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicBookingServicesEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /public/store/{slug}/availability:
    get:
      tags: [Bookings]
      operationId: getPublicStoreSlugAvailability
      summary: Public, token-free derived free slots for one service/date (WACRM-hosted booking page)
      description: >-
        Same derivation as `GET /crm-projects/{project}/availability`
        (`App\Wacrm\Booking\BookingService::availability`), resolved by the
        public `{slug}` instead of a credential.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
        - name: service
          in: query
          required: true
          schema:
            type: string
            format: uuid
        - name: date
          in: query
          required: true
          description: Calendar date, `YYYY-MM-DD`, interpreted in each candidate staff member's own timezone.
          schema:
            type: string
            format: date
        - name: staff
          in: query
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvailabilitySlotCollection"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          description: No business with that slug, or no service/staff member with that id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /public/store/{slug}/bookings:
    post:
      tags: [Bookings]
      operationId: postPublicStoreSlugBookings
      summary: Public, token-free booking create (WACRM-hosted booking page)
      description: >-
        Creates the exact same `wacrm_bookings` row
        `POST /crm-projects/{project}/bookings` creates — same overlap lock,
        same `Idempotency-Key` contract (honored when the page sends one,
        minted server-side otherwise; public keys live in their own
        namespace, so a public call never replays a Workspace-created
        booking). The contact is resolved/created by
        `contact.phone` (+ optional `contact.name`) rather than a
        `contact_id`, since an anonymous visitor has no Workspace contact id
        to send.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 140
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublicBookingCreate"
      responses:
        "201":
          description: Created (or replayed from an existing Idempotency-Key)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookingEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          description: No business with that slug, or no service/staff member with that id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: slot_taken — this slot already has an active booking
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: outside_staff_hours — outside the chosen staff member's working hours
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /crm-projects/{project}/contacts/{contact}/billing-history:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectContactsContactBillingHistory
      x-scopes: [projects:read]
      summary: A contact's payment/billing history, for the Inbox contact panel (R-WRK-04)
      description: >-
        Read-only. Same owner-isolation rule as `GET /crm-projects/{project}/plan`
        (a project id this credential doesn't own 404s, never 403); the contact
        id is then looked up scoped to that same project, same 404-never-403
        rule. Newest first, paginated like every other v1 list (`page`/`limit`,
        capped at 100). Reads only `App\Models\Payment` rows — no row is ever
        shown unless it is scoped to this contact's own project AND its
        `source_customer_id` matches this contact's phone (normalised digits)
        or email. No payment-creation path in this job writes
        `source_customer_id` yet, so this honestly returns an empty list for
        every real contact today — never a merchant's own wallet/plan/
        store-subscription row. The work-management revenue read-back (R-WRK-04's
        other half) is blocked on R-WRK-05 and not built here — see
        docs/handoff/p24-m4-work-finance.md.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/ContactId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactBillingCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/cost-report:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectCostReport
      x-scopes: [cost_reports:read]
      summary: Meta WhatsApp cost report (by number/campaign/date range) and a 30-day forecast (job m5-cost-reports, R-WA-09)
      description: >-
        Same owner-isolation rule as `GET /crm-projects/{project}/plan` (a
        project id this credential doesn't own 404s, never 403). Reads only
        the pre-aggregated `wacrm_message_cost_daily` rollup, grouped by Meta
        pricing category (`marketing`/`utility`/`authentication`/`service`) —
        every number is a WACRM design ESTIMATE
        (`config/wacrm_cost.php`), never an authoritative Meta price. A
        category with no rollup rows in the requested range reads `null` in
        both `by_category` and `forecast`, never `0`. `date_from`/`date_to`
        default to the trailing 30 days. `number` filters to one connected
        WhatsApp number (`wacrm_channel_accounts.uuid`); `campaign` filters
        to one template (`wacrm_templates.uuid`) — either filter given with a
        uuid not found in this project 404s (the same "unknown id, not an
        error shape with no data" rule as the project id itself), rather than
        silently returning an empty report. `date_from`/`date_to` must be
        `Y-m-d`, `date_from` must not be after `date_to`, and the range must
        not exceed 366 days — each violation 400s `invalid_input`.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: number
          in: query
          required: false
          description: Filter to one connected WhatsApp number's uuid (`wacrm_channel_accounts.uuid`)
          schema:
            type: string
            format: uuid
        - name: campaign
          in: query
          required: false
          description: Filter to one template's uuid (`wacrm_templates.uuid`), used here as the campaign dimension
          schema:
            type: string
            format: uuid
        - name: date_from
          in: query
          required: false
          schema:
            type: string
            format: date
        - name: date_to
          in: query
          required: false
          schema:
            type: string
            format: date
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CostReportEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/contacts/{contact}/quotes:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectContactsContactQuotes
      x-scopes: [quotations:read]
      summary: A contact's quotes ("Deal"/"Quote draft"), for the Sales B2B Chrome edition (job m8-chrome-sales-b2b)
      description: >-
        Read-only. Same owner-isolation rule as `GET /crm-projects/{project}/contacts/{contact}/
        billing-history` (a project id this credential doesn't own 404s, never 403, including a
        sibling project of the same owner; the contact id is then looked up scoped to that same
        project, same rule). Wraps the existing `App\Models\QuotationRequest` model — "deal" is
        not a new entity (`docs/plan/p28-m8-jobs.md` §4 open question 2). A quote is matched to the
        contact by phone (normalised digits) or email, same convention `GET .../billing-history`
        already established for `Payment`. `opened_at`/`view_count` (job `m4-quotes-catalog`) are
        included only when that job's migration has run; `price`/`status` are returned exactly as
        stored, including null, never fabricated.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/ContactId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactQuoteCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /crm-projects/{project}/social-comments:
    get:
      tags: [Projects]
      operationId: getCrmProjectsProjectSocialComments
      x-scopes: [meta_comments:read]
      summary: List this project's Facebook Page / Instagram comments
      description: >-
        Behind `WACRM_META_DM_ENABLED` (default off, shared with
        `P23-meta-dm`): `503 feature_disabled` while the flag is off. Same
        owner-isolation rule as `GET /crm-projects/{project}/plan`: a
        project id this credential doesn't own 404s, never 403. Optional
        `status` query filter (`visible`/`hidden`/`deleted`).
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [visible, hidden, deleted]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialCommentCollection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not owned by this credential
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/MetaCommentsDisabled"

  /crm-projects/{project}/social-comments/{comment}/replies:
    post:
      tags: [Projects]
      operationId: postCrmProjectsProjectSocialCommentsCommentReplies
      x-scopes: [meta_comments:write]
      summary: Propose a public reply to a comment (ALWAYS needs human approval)
      description: >-
        Design §6.6: a public comment reply is a brand-risk surface, so this
        never posts to Meta directly — it proposes a `wacrm_agent_actions`
        row via `App\Wacrm\Agent\PolicyEngine`, which decides
        `needs_approval` **unconditionally** for this tool regardless of its
        configured risk tier. Only `POST /agent-actions/{action}/approve`
        actually sends it, through `App\Wacrm\MetaComments\SocialCommentSender`.
        A comment with `status: deleted` answers `409 comment_deleted`
        before any proposal is created.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: comment
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  maxLength: 2000
      responses:
        "201":
          description: Created — a `needs_approval` agent action proposing the reply
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialCommentReplyEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Comment deleted upstream (`comment_deleted`), or AI is paused for this project (`ai_paused`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/MetaCommentsDisabled"

  /crm-projects/{project}/social-comments/{comment}/private-reply:
    post:
      tags: [Projects]
      operationId: postCrmProjectsProjectSocialCommentsCommentPrivateReply
      x-scopes: [meta_comments:write]
      summary: Send a one-time private reply to a comment
      description: >-
        A direct human action — sent immediately through
        `App\Wacrm\MetaComments\SocialCommentSender`, never through the
        agent-action queue (design §6.6 "always needs a human"). Exactly one
        private reply is allowed per comment (design §6.7): a second call
        answers `409 private_reply_already_sent`. A comment with
        `status: deleted` answers `409 comment_deleted`.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: comment
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  maxLength: 2000
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialCommentEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Comment deleted upstream (`comment_deleted`), or a private reply was already sent (`private_reply_already_sent`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/MetaCommentsDisabled"

  /crm-projects/{project}/social-comments/{comment}/hide:
    post:
      tags: [Projects]
      operationId: postCrmProjectsProjectSocialCommentsCommentHide
      x-scopes: [meta_comments:write]
      summary: Hide a comment (direct human action)
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: comment
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialCommentEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Comment deleted upstream (`comment_deleted`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: provider_error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/MetaCommentsDisabled"

  /crm-projects/{project}/social-comments/{comment}:
    delete:
      tags: [Projects]
      operationId: deleteCrmProjectsProjectSocialCommentsComment
      x-scopes: [meta_comments:write]
      summary: Delete a comment (direct human action)
      description: >-
        Idempotent — a comment already `status: deleted` is left as-is and
        answers `200`, not an error.
      security:
        - BasicAuth: []
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: comment
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialCommentEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          description: provider_error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/MetaCommentsDisabled"

  /ai/transcribe-voice:
    post:
      tags: [AI]
      operationId: postAiTranscribe-voice
      x-scopes: [ai:write]
      summary: Transcribe an existing voice-note attachment of this project (job P27-ai-services-batch-c)
      description: >-
        Input is an existing message attachment id of the caller's own project (another project's
        id is `404`). Audio only (`422 unsupported_media` otherwise); the attachment needs a known
        duration of at most 120 seconds (`422 audio_too_long` otherwise). Both checks run before any
        provider call or wallet deduction. Billed `ceil(seconds / 60)` units at the
        `transcribe_voice` price, refunded if the provider fails or returns no text. A provider that
        cannot transcribe audio answers `501 ai_capability_unavailable` (net zero tokens).
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attachment_id]
              properties:
                attachment_id:
                  type: integer
                  minimum: 1
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      text:
                        type: string
                      detected_language:
                        type: string
                        nullable: true
                      minutes_billed:
                        type: integer
                      provider:
                        type: string
                      tokens_deducted:
                        type: integer
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/AiPlanOrScopeForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: "`unsupported_media` (not audio) or `audio_too_long` (no duration or over 120 s)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "501":
          description: "`ai_capability_unavailable` — the configured AI provider cannot transcribe audio"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/ocr-image:
    post:
      tags: [AI]
      operationId: postAiOcr-image
      x-scopes: [ai:write]
      summary: Extract text from an existing image attachment of this project (job P27-ai-services-batch-c)
      description: >-
        Input is an existing message attachment id of the caller's own project (another project's
        id is `404`). Image only: any other kind is `422 unsupported_media` before any provider call
        or wallet deduction. Billed once at the `ocr_image` price, refunded if the provider fails or
        returns no text.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attachment_id]
              properties:
                attachment_id:
                  type: integer
                  minimum: 1
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      text:
                        type: string
                      provider:
                        type: string
                      tokens_deducted:
                        type: integer
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/AiPlanOrScopeForbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: "`unsupported_media` — the attachment is not an image"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/AiSandboxRateLimited"
        "501":
          description: "`ai_capability_unavailable` — the configured AI provider cannot run OCR"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/draft-template:
    post:
      tags: [AI]
      operationId: postAiDraft-template
      x-scopes: [ai:write]
      summary: Draft a WhatsApp template body from a freeform instruction (job P27-ai-services-template-tools)
      description: >-
        Drafts through the existing `AiProvider::suggestReply()` method. The draft is always run
        back through `TemplateLintService` before it is returned — a draft that still fails
        brand-safety lint is never handed to the caller, it is rejected (`502
        ai_output_rejected`) and the wallet deduction for the call is refunded.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instruction]
              properties:
                instruction:
                  type: string
                  maxLength: 2000
                language:
                  type: string
                  maxLength: 20
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiDraftTemplateResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/AiOutputRejected"
        "503":
          $ref: "#/components/responses/AiUnavailable"

  /ai/check-template-policy:
    post:
      tags: [AI]
      operationId: postAiCheck-template-policy
      x-scopes: [ai:read]
      summary: Check draft template content against the brand-safety lint rules (job P27-ai-services-template-tools)
      description: >-
        Composes the existing `App\Wacrm\Messaging\Templates\TemplateLintService` (job
        P23-template-lint) as its only rule list — never a second/duplicated checklist. Rule-based,
        no `AiProvider` call; free by default (`config('wallet.services.ai.costs.check_template_policy')`).
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                  maxLength: 4096
                header:
                  type: string
                  maxLength: 4096
                footer:
                  type: string
                  maxLength: 4096
                buttons:
                  type: array
                  items:
                    type: object
                    properties:
                      text:
                        type: string
                      url:
                        type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AiCheckTemplatePolicyResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "402":
          $ref: "#/components/responses/InsufficientTokens"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /care/alerts:
    post:
      tags: [Care]
      operationId: postCareAlerts
      x-scopes: [care:alerts:write]
      summary: Post a keyed Care alert, routed to the project's recipient #1
      description: >-
        Job P25-m5-care-relay-a (R-CARE-01, mechanism-only — D7 gate,
        docs/plan/p25-m5-jobs.md hard-limits box). Behind
        `WACRM_CARE_RELAY_ENABLED` (default off): `503 feature_disabled`
        while the flag is off. `route_key` resolves to this project's own
        `wacrm_relay_routes` row (cross-project or unknown key: `404
        not_found`, never 403) and sends to its recipient #1 only — the
        escalation walk to the next recipient on an ack timeout is a later
        job (Part B). `template` must already be an `approved`
        `wacrm_care_*` row for this project and pass the R-CARE-05
        referral/invite content lint (`422 template_not_approved`
        otherwise, nothing sent). The send always defaults to text plus a
        short-lived signed link; `image_url` is refused with `422
        image_consent_required` unless this contact already has a recorded
        `opted_in` consent row (R-CARE-06). Requires an `Idempotency-Key`
        header — a retry with the same key replays the original response
        verbatim (no time window; failures replay too). Recipient #1 needs an
        `opted_in` WhatsApp consent row (R-WA-08), else `409 consent_required`.
      security:
        - ApiKeyAuth: []
        - BasicAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            maxLength: 150
          description: Replays the original response verbatim on retry with the same key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CareAlertCreate"
      responses:
        "200":
          description: Sent (or replayed from a duplicate Idempotency-Key)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CareAlertEnvelope"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No active route with that route_key for this project
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: consent_required (recipient #1 has no WhatsApp opt-in) or window_closed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: template_not_approved, image_consent_required, no_recipients, or wa_template_protected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: provider_error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: feature_disabled — WACRM_CARE_RELAY_ENABLED is off
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /care/alerts/{alert}/ack/{token}:
    get:
      tags: [Care]
      operationId: getCareAlertAck
      summary: Public, token-guarded ack of a Care alert — stops further escalation
      description: >-
        Job P25-m5-care-relay-b (R-CARE-02). No API key, no Workspace session
        — the unguessable token in the path is its own credential, same shape
        as `GET /client-portal/{token}`. Only the token's SHA-256 hash is
        ever stored (`wacrm_care_alerts.link_token_hash`), never the raw
        value. A missing alert, a wrong token, or a token whose link has
        expired all answer the exact same `404 not_found` — never a distinct
        status. A second ack of an already-acknowledged chain is a no-op
        `200` (`"status": "already_acknowledged"`), never a second write.
        Acking any hop of an escalation chain stops `CareEscalationScheduler`
        from sending to the next recipient. `POST` on the same path runs the
        identical handler.
      security: []
      parameters:
        - name: alert
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Acknowledged (or already acknowledged — idempotent)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CareAlertAckEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: feature_disabled — WACRM_CARE_RELAY_ENABLED is off
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      tags: [Care]
      operationId: postCareAlertAck
      summary: Same handler as GET — ack a Care alert via an explicit POST
      description: >-
        Identical behavior to `GET /care/alerts/{alert}/ack/{token}` above;
        kept for a caller that prefers an explicit non-GET action.
      security: []
      parameters:
        - name: alert
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Acknowledged (or already acknowledged — idempotent)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CareAlertAckEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: feature_disabled — WACRM_CARE_RELAY_ENABLED is off
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: |
        API key (`sk_acc_live_...` account-level or `sk_live_...` project-level),
        issued from Workspace → Developer/API/MCP, or the scoped token minted by
        the extension device-code login. Send as `Authorization: Bearer <key>`.
      bearerFormat: sk_*
    BasicAuth:
      type: http
      scheme: basic
      description: Project `sid:secret`, for server-to-server integrations (e.g. the Wix app).
    BearerAuth:
      type: http
      scheme: bearer
      description: Workspace user JWT, plus `X-WACRM-Project-Id` header to select the active project.
      bearerFormat: JWT

  parameters:
    SessionId:
      name: session_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    Window:
      name: window
      in: query
      description: Filter conversations to those with an open 24h customer-service send window
      schema:
        type: string
        enum: [open]
    ProductId:
      name: product
      in: path
      required: true
      schema:
        type: string
        format: uuid
    OrderId:
      name: order
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ProjectId:
      name: project
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ContactId:
      name: contact
      in: path
      required: true
      schema:
        type: string
        format: uuid
    LabelId:
      name: label
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TicketId:
      name: ticket
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TemplateId:
      name: template
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ConnectSessionId:
      name: session
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ChannelId:
      name: channel
      in: path
      required: true
      schema:
        type: string
        format: uuid
    WebhookEndpointId:
      name: endpoint
      in: path
      required: true
      schema:
        type: string
        format: uuid
    BroadcastId:
      name: broadcast
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ConversationId:
      name: conversation
      in: path
      required: true
      schema:
        type: string
        format: uuid
    AgentActionId:
      name: action
      in: path
      required: true
      schema:
        type: string
        format: uuid
    AgentActionStatus:
      name: status
      in: query
      schema:
        type: string
        enum: [proposed, executing, rejected, expired, succeeded, failed]
    GlossaryTermId:
      name: term
      in: path
      required: true
      schema:
        type: string
        format: uuid
    BookingServiceId:
      name: service
      in: path
      required: true
      schema:
        type: string
        format: uuid
    BookingId:
      name: booking
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ClientPortalLinkId:
      name: link
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ClientPortalToken:
      name: token
      in: path
      required: true
      description: The raw, unguessable token returned once by the mint endpoint — not the link's `id`.
      schema:
        type: string
    BookingStaffIdQuery:
      name: staff
      in: query
      description: Narrow availability to one staff member; omitted considers every active staff member.
      schema:
        type: string
        format: uuid
    Search:
      name: search
      in: query
      schema:
        type: string
    Page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  responses:
    InvalidInput:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid credential
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: Credential lacks the required scope
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AiPlanOrScopeForbidden:
      description: >-
        Missing a required scope (`insufficient_scope`), or — for an API-key caller only, a
        Workspace JWT/Basic session is never gated — `ai_api_requires_pro_plan` when the project's
        plan is below Pro/Agency (`wacrm.ai.plan_gate`, job P27-ai-gateway-plan-gate).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AiSandboxRateLimited:
      description: >-
        Too many requests (`throttle:services-ai`), or — API-key caller only —
        `sandbox_limit_reached` once a `sk_acc_test_…` sandbox key's daily free-call cap is used up
        (`wacrm.ai.plan_gate`, job P27-ai-gateway-plan-gate).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found (or not owned by this project)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InsufficientTokens:
      description: Wallet balance too low for this AI call
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AiUnavailable:
      description: >-
        AI is temporarily unavailable (upstream error, timeout, or the monthly
        spend cap was reached). The wallet deduction for this call is refunded.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AiOutputRejected:
      description: >-
        `ai_output_rejected` — the AI provider answered, but its output failed server-side
        re-validation (an out-of-enum `intent`/`sentiment`, a `confidence` outside `[0, 1]`, or an
        `extract_entities` field that doesn't match its kind's schema). The raw provider output is
        never returned to the caller; the wallet deduction for this call is refunded.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    TemplateLintFailed:
      description: >-
        `template_lint_failed` — the template body (or a header/footer/button
        text or url sent with it) contains WACRM self-promotion or a misused
        placeholder (`{{0}}`, unbalanced braces). Nothing is created or changed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/TemplateLintError"
    PlanLimitReached:
      description: >-
        `plan_limit_reached` — the project's plan limit for this resource is
        already used up. Upgrade the plan to add more.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PlanLimitError"
    MetaCommentsDisabled:
      description: >-
        `feature_disabled` — `WACRM_META_DM_ENABLED` is off (job
        P23-meta-comments, flag shared with P23-meta-dm).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    AiDraftTemplateResult:
      type: object
      properties:
        data:
          type: object
          properties:
            body:
              type: string
              description: >-
                The drafted template body. Always re-checked against `TemplateLintService` before
                being returned — a draft that fails that check is `502 ai_output_rejected` instead.
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer
    AiCheckTemplatePolicyResult:
      type: object
      properties:
        data:
          type: object
          properties:
            passed:
              type: boolean
            violations:
              type: array
              description: Same `{field, phrase}` shape as `template_lint_failed`'s `error.details`.
              items:
                type: object
                properties:
                  field:
                    type: string
                  phrase:
                    type: string
            tokens_deducted:
              type: integer
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              example: insufficient_scope
            message:
              type: string
              example: "This API key is missing the contacts:write scope."

    TemplateLintError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum: [template_lint_failed]
            message:
              type: string
              example: Template content failed brand-safety lint.
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                    example: body
                  phrase:
                    type: string
                    example: powered by wacrm
            language:
              type: string
              description: >-
                Set only when the lint failure is on a `target_languages`
                variant (job P23-translation-templates) — the target
                language code of the rejected variant, not present for a
                rejection of the template's own (source) body.

    PlanLimitError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum: [plan_limit_reached]
            message:
              type: string
            resource:
              type: string
              enum: [contacts, channels, seats]
            limit:
              type: integer
            current:
              type: integer

    AiPauseUpdate:
      type: object
      minProperties: 1
      properties:
        paused:
          type: boolean
        auto_send:
          type: boolean
        auto_book:
          type: boolean
          nullable: true
          description: >-
            Separate opt-in for unattended booking execution (job
            P24-m4-followups). `null` inherits `auto_send`.

    AiPauseEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            paused:
              type: boolean
            auto_send:
              type: boolean
            auto_book:
              type: boolean
              nullable: true

    StorageUsageEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            bytes_used:
              type: integer
            quota_bytes:
              type: integer
              nullable: true
              description: Null when the plan states no `storage_gb` figure (unlimited).
            percent_used:
              type: number
              nullable: true
            over_quota:
              type: boolean
            warned:
              type: boolean
              description: Whether the one-shot 80%-crossing notification is currently armed.

    GlossaryTermCreate:
      type: object
      required: [source_lang, target_lang, term, replacement]
      properties:
        source_lang:
          type: string
          maxLength: 20
        target_lang:
          type: string
          maxLength: 20
          example: pt-BR
        term:
          type: string
          maxLength: 255
        replacement:
          type: string
          maxLength: 500

    GlossaryTerm:
      type: object
      properties:
        id:
          type: string
          format: uuid
        source_lang:
          type: string
        target_lang:
          type: string
        term:
          type: string
        replacement:
          type: string
        created_at:
          type: [string, "null"]
          format: date-time

    GlossaryTermEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/GlossaryTerm"

    GlossaryTermCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/GlossaryTerm"

    OrderAutomationUpdate:
      type: object
      properties:
        enabled:
          type: boolean
        order_confirmation_template:
          type: [string, "null"]
          description: Name of an existing template in this project, sent when a new order arrives
        cod_confirm_template:
          type: [string, "null"]
          description: Name of an existing template in this project, sent to confirm a cash-on-delivery order
        language:
          type: [string, "null"]
          maxLength: 20

    OrderAutomationEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            enabled:
              type: boolean
            order_confirmation_template:
              type: [string, "null"]
            cod_confirm_template:
              type: [string, "null"]
            language:
              type: string
              example: en

    OrderConfirmationEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            order_id:
              type: string
              format: uuid
            is_cod:
              type: boolean
            order_confirmation_sent_at:
              type: [string, "null"]
              format: date-time
            cod_status:
              type: [string, "null"]
              enum: [pending, confirmed, cancel_requested, null]
            cod_template_sent_at:
              type: [string, "null"]
              format: date-time
            cod_responded_at:
              type: [string, "null"]
              format: date-time
            skip_reason:
              type: [string, "null"]
              enum: [no_phone, no_consent, template_missing, template_not_approved, send_failed, null]

    Conversation:
      type: object
      properties:
        id:
          type: string
          format: uuid
        contact:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: [string, "null"]
            phone:
              type: string
        last_message:
          type: [object, "null"]
          properties:
            direction:
              type: string
              enum: [inbound, outbound]
            type:
              type: string
              enum: [text, template]
            content:
              type: string
              maxLength: 120
              description: Truncated preview of the message content
            created_at:
              type: string
              format: date-time
        last_inbound_at:
          type: [string, "null"]
          format: date-time
        window_open:
          type: boolean
          description: Whether the 24h customer-service send window is currently open
        unread:
          type: "null"
          description: Not tracked yet — always null
        channel_account_id:
          type: [string, "null"]
          format: uuid

    ConversationCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Conversation"

    ProductVariant:
      type: object
      description: >-
        A SKU-level variant (Shopify per-variant child product, or a Wix
        catalog variant). `price`/`currency`/`stock` are read exactly as the
        sync job stored them — never computed or defaulted.
      properties:
        id:
          type: [string, "null"]
          format: uuid
        sku:
          type: [string, "null"]
        price:
          type: [string, "null"]
          description: Decimal string exactly as stored, never rounded.
        currency:
          type: [string, "null"]
        stock:
          type: [string, integer, "null"]

    Product:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        status:
          type: string
        sku:
          type: [string, "null"]
        price:
          type: [string, "null"]
          description: >-
            Decimal string exactly as stored by the sync job. `null` (never
            `0`) when no price was ever synced for this product.
        currency:
          type: [string, "null"]
        stock:
          type: [string, integer, "null"]
        variants:
          type: array
          items:
            $ref: "#/components/schemas/ProductVariant"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProductListItem:
      allOf:
        - $ref: "#/components/schemas/Product"
        - type: object
          properties:
            variants:
              type: "null"
              description: Omitted in the list view; see `GET /commerce/products/{product}`.

    ProductEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Product"
    ProductCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ProductListItem"

    OrderContact:
      type: object
      properties:
        name:
          type: [string, "null"]
        phone:
          type: [string, "null"]
        email:
          type: [string, "null"]

    OrderLine:
      type: object
      description: >-
        An order line. `price` is always `null` today — no kept sync path
        reliably links a line to its point-in-time price (see
        docs/handoff/p26-j1b-commerce-api.md §1.3); the API never guesses one.
      properties:
        id:
          type: string
          format: uuid
        quantity:
          type: integer
        product_id:
          type: [string, "null"]
          format: uuid
        sku:
          type: [string, "null"]
        price:
          type: "null"
        currency:
          type: "null"
        external_product_id:
          type: [string, "null"]
        external_variant_id:
          type: [string, "null"]

    Order:
      type: object
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
        platform:
          type: [string, "null"]
        external_order_id:
          type: [string, "null"]
        external_order_number:
          type: [string, "null"]
        currency:
          type: [string, "null"]
        total:
          type: [string, "null"]
        contact:
          $ref: "#/components/schemas/OrderContact"
        lines:
          type: array
          items:
            $ref: "#/components/schemas/OrderLine"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    OrderListItem:
      allOf:
        - $ref: "#/components/schemas/Order"
        - type: object
          properties:
            lines:
              type: "null"
              description: Omitted in the list view; see `GET /commerce/orders/{order}`.

    OrderEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Order"
    OrderCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/OrderListItem"

    Segment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        rules:
          type: object
          properties:
            label_ids:
              type: array
              items:
                type: string
                format: uuid
            has_open_window:
              type: boolean
            search:
              type: string
        created_at:
          type: string
          format: date-time

    SegmentEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Segment"

    SegmentCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Segment"

    Consent:
      type: object
      properties:
        channel:
          type: string
          enum: [whatsapp]
        status:
          type: string
          enum: [opted_in, opted_out, unknown]
        source:
          type: [string, "null"]
        recorded_at:
          type: [string, "null"]
          format: date-time

    ConsentEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Consent"

    Broadcast:
      type: object
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [draft, running, paused, done, failed]
        segment_id:
          type: [string, "null"]
          format: uuid
        template_id:
          type: [string, "null"]
          format: uuid
        pacing_per_minute:
          type: integer
        budget_cap:
          type: [integer, "null"]
        counts:
          type: object
          properties:
            total:
              type: integer
            sent:
              type: integer
            skipped:
              type: integer
            failed:
              type: integer
        started_at:
          type: [string, "null"]
          format: date-time
        finished_at:
          type: [string, "null"]
          format: date-time

    BroadcastEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Broadcast"

    WorkTaskCreate:
      type: object
      required: [title]
      properties:
        title:
          type: string
          maxLength: 255
        due_at:
          type: [string, "null"]
          format: date-time
        assignee_ref:
          type: [string, "null"]
          maxLength: 191

    WorkTask:
      type: object
      properties:
        id:
          type: string
          format: uuid
        conversation_id:
          type: string
          format: uuid
        title:
          type: string
        due_at:
          type: [string, "null"]
          format: date-time
        assignee_ref:
          type: [string, "null"]
        status:
          type: string
          enum: [open, done, cancelled]
        external_ref:
          type: [string, "null"]
          description: Opaque id on the Work side once the bridge has delivered this task — never a vendor name or URL.
        created_at:
          type: string
          format: date-time

    WorkTaskEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/WorkTask"

    WorkTaskCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/WorkTask"

    AgentActionCreate:
      type: object
      required: [tool]
      properties:
        tool:
          type: string
          enum: [read, add_note, suggest, send_message, send_template, broadcast_create, set_consent, close]
        conversation_id:
          type: [string, "null"]
          format: uuid
        input:
          type: object
          description: Free-form, tool-specific. PII (email, card numbers, national id, OTP) is redacted before storage.

    AgentAction:
      type: object
      properties:
        id:
          type: string
          format: uuid
        conversation_id:
          type: [string, "null"]
          format: uuid
        tool:
          type: string
        input:
          type: object
        risk:
          type: [string, "null"]
          enum: [low, medium, high, null]
        policy_decision:
          type: string
          enum: [auto, needs_approval, denied]
        status:
          type: string
          enum: [proposed, executing, rejected, expired, succeeded, failed]
        expires_at:
          type: [string, "null"]
          format: date-time
        token_cost:
          type: integer
        result:
          type: [object, "null"]
        error:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time

    AgentActionEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/AgentAction"

    AgentActionCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/AgentAction"

    BookingAgentAction:
      type: object
      description: job m4-booking-ai-receptionist — the `booking_action` shape of an agent action (no `conversation_id`; carries `kind` instead).
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [book, reschedule, cancel]
        tool:
          type: string
          enum: [booking_action]
        input:
          type: object
        risk:
          type: [string, "null"]
          enum: [low, medium, high, null]
        policy_decision:
          type: string
          enum: [auto, needs_approval, denied]
        status:
          type: string
        result:
          type: [object, "null"]
        error:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time

    BookingAgentActionEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/BookingAgentAction"

    WorkCallback:
      type: object
      required: [project, external_ref, status]
      properties:
        project:
          type: string
          description: The WACRM project id sent with the original task create, echoed back verbatim
        external_ref:
          type: string
        status:
          type: string
          enum: [open, done, cancelled]

    ExtensionSessionCreated:
      type: object
      properties:
        data:
          type: object
          properties:
            session_id:
              type: string
              format: uuid
            user_code:
              type: string
              example: "WACR-7X2K"
            verification_uri:
              type: string
              format: uri
              example: "https://app.wacrm.ai/extension/approve?session=9b8f8b8e-2b7b-4b8e-8b8e-2b7b4b8e8b8e&code=WACR-7X2K"
            expires_in:
              type: integer
              example: 600
            interval:
              type: integer
              example: 3
              description: Minimum seconds between poll requests

    ExtensionSessionStatus:
      type: object
      properties:
        data:
          type: object
          properties:
            status:
              type: string
              enum: [pending, approved, expired]
            access_token:
              type: [string, "null"]
            token_type:
              type: [string, "null"]
              example: bearer
            project_id:
              type: [string, "null"]
              format: uuid

    Project:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        status:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProjectEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Project"

    ProjectCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Project"

    Plan:
      type: object
      properties:
        key:
          type: string
          enum: [starter, growth, pro, agency]
        name:
          type: string
        monthly_usd:
          type: integer
        seats:
          type: [integer, "null"]
        contacts:
          type: [integer, "null"]
        channels:
          type: [integer, "null"]
          description: Connected WhatsApp numbers included (`null` = not limited)
        storage_gb:
          type: integer

    PlanUsage:
      type: object
      properties:
        seats_used:
          type: integer
        seats_limit:
          type: [integer, "null"]
        contacts_used:
          type: integer
        contacts_limit:
          type: [integer, "null"]
        channels_used:
          type: integer
        channels_limit:
          type: [integer, "null"]

    ProjectPlanEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            current_plan:
              type: string
              enum: [starter, growth, pro, agency]
            plan:
              type: string
              enum: [starter, growth, pro, agency]
              description: Same value as `current_plan`
            trial_ends_at:
              type: [string, "null"]
              format: date-time
              description: End of the 14-day trial a new project starts with; `null` when the project has no trial
            trial_active:
              type: boolean
            plans:
              type: array
              items:
                $ref: "#/components/schemas/Plan"
            usage:
              $ref: "#/components/schemas/PlanUsage"
            upgrade_url:
              type: string
              format: uri
              example: https://wacrm.ai/pricing/

    WalletEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            balance:
              type: integer
              example: 850
            plan_allowance:
              type: [integer, "null"]
            usage_this_cycle:
              type: integer
            low_balance:
              type: boolean

    Label:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time

    LabelEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Label"
    LabelCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Label"

    Contact:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: [string, "null"]
        phone:
          type: string
          example: "+15551234567"
        email:
          type: [string, "null"]
        labels:
          type: array
          items:
            $ref: "#/components/schemas/Label"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ContactCreate:
      type: object
      required: [phone]
      properties:
        name:
          type: [string, "null"]
        phone:
          type: string
        email:
          type: [string, "null"]

    ContactUpdate:
      type: object
      properties:
        name:
          type: [string, "null"]
        email:
          type: [string, "null"]

    ContactEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Contact"
    ContactCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Contact"

    Note:
      type: object
      properties:
        id:
          type: string
          format: uuid
        body:
          type: string
        created_at:
          type: string
          format: date-time

    NoteEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Note"
    NoteCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Note"

    Ticket:
      type: object
      properties:
        id:
          type: string
          format: uuid
        subject:
          type: string
        body:
          type: string
        status:
          type: string
          enum: [open, pending, closed]
        priority:
          type: string
          enum: [low, normal, high]
        type:
          type: string
          enum: [support, service_request]
        booking_id:
          type: [string, "null"]
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TicketEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Ticket"
    TicketCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Ticket"

    ContactBillingItem:
      type: object
      description: >-
        One `App\Models\Payment` row. See `GET
        /crm-projects/{project}/contacts/{contact}/billing-history` for the
        contact-matching rule and why this list is empty against today's real
        data.
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
        type:
          type: string
          nullable: true
        price:
          type: number
          format: float
        currency:
          type: string
          nullable: true
        billing_product:
          type: string
          nullable: true
        billing_cycle:
          type: string
          nullable: true
        provider:
          type: string
          nullable: true
        activated_on:
          type: string
          nullable: true
        billing_on:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
    ContactBillingCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ContactBillingItem"

    ContactQuoteItem:
      type: object
      description: >-
        One `App\Models\QuotationRequest` row. See `GET
        /crm-projects/{project}/contacts/{contact}/quotes` for the
        contact-matching rule. `opened_at`/`view_count` are present only when
        job `m4-quotes-catalog`'s migration has added those columns.
      properties:
        id:
          type: string
          format: uuid
        note:
          type: string
          nullable: true
        price:
          type: number
          format: float
          nullable: true
        status:
          type: string
        opened_at:
          type: string
          format: date-time
          nullable: true
        view_count:
          type: integer
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    ContactQuoteCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ContactQuoteItem"

    TemplateCreate:
      type: object
      required: [name, body]
      properties:
        name:
          type: string
        language:
          type: string
          default: en
        category:
          type: [string, "null"]
        body:
          type: string

    TemplateVariantCreate:
      description: >-
        `POST /message-templates` only (job P23-translation-templates,
        R-TRN-03). `target_languages` is optional — omitting it behaves
        exactly like `TemplateCreate`.
      allOf:
        - $ref: "#/components/schemas/TemplateCreate"
        - type: object
          properties:
            target_languages:
              type: array
              maxItems: 5
              items:
                type: string
              description: >-
                Each code must be distinct, different from `language`, and
                one of the allowed WhatsApp template language codes
                (`App\Http\Requests\Wacrm\TemplateStoreRequest::ALLOWED_LANGUAGE_CODES`).
                One sibling template row is created per code, body
                translated via `AiService::translate()`, sharing the
                response's `variant_group_id`.

    Template:
      allOf:
        - $ref: "#/components/schemas/TemplateCreate"
        - type: object
          properties:
            id:
              type: string
              format: uuid
            status:
              type: string
              enum: [draft, pending, approved, rejected]
            created_at:
              type: string
              format: date-time
            variant_group_id:
              type: [string, "null"]
              description: >-
                Set when this template was created with `target_languages`
                (or is itself one of the resulting variants) — shared by
                every sibling row. `null` for a template with no variants.
            variants:
              type: array
              description: >-
                Only present on the `POST /message-templates` response that
                created the variants, listing the sibling rows just created
                (never includes this template itself).
              items:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  language:
                    type: string

    TemplateEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Template"
    TemplateCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Template"

    Message:
      type: object
      properties:
        id:
          type: integer
          description: Numeric watermark id, use as `since_id` for the next page
        direction:
          type: string
          enum: [inbound, outbound]
        type:
          type: string
          enum: [text, template]
        content:
          type: string
        template_id:
          type: [string, "null"]
          format: uuid
        status:
          type: [string, "null"]
          enum: [sent, delivered, read, failed]
        created_at:
          type: string
          format: date-time

    MessageCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Message"
        next_since_id:
          type: integer

    MessageSend:
      type: object
      required: [contact_id, type]
      properties:
        contact_id:
          type: string
          format: uuid
        type:
          type: string
          enum: [text, template]
        text:
          type: string
          maxLength: 4096
          description: Required when type=text
        template_id:
          type: string
          format: uuid
          description: Required when type=template
        params:
          type: array
          items:
            type: string
          description: Positional values substituted into the template body

    MessageSendResult:
      type: object
      properties:
        data:
          type: object
          properties:
            message_id:
              type: string
              format: uuid
            external_id:
              type: [string, "null"]
            status:
              type: string

    AiTranslateResult:
      type: object
      properties:
        data:
          type: object
          properties:
            translated_text:
              type: string
            detected_source_lang:
              type: [string, "null"]
            engine_used:
              type: string
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer
              description: >-
                Billed per 500-character unit of the input text; `0` on a
                cache hit.
            cached:
              type: boolean
              description: >-
                `true` when this exact text/language pair (and glossary state)
                was already translated for this project — no provider call,
                nothing deducted.

    AiDetectLanguageResult:
      type: object
      properties:
        data:
          type: object
          properties:
            language_code:
              type: string
            confidence:
              type: number
              format: float
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    AiSummaryResult:
      type: object
      properties:
        data:
          type: object
          properties:
            summary:
              type: string
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    AiSuggestReplyResult:
      type: object
      properties:
        data:
          type: object
          properties:
            suggestions:
              type: array
              items:
                type: string
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    AiClassifyMessageResult:
      type: object
      properties:
        data:
          type: object
          properties:
            intent:
              type: string
              enum: [question, complaint, order_status, purchase_intent, support_request, other]
            sentiment:
              type: string
              enum: [positive, neutral, negative]
            confidence:
              type: number
              format: float
              minimum: 0
              maximum: 1
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    AiExtractEntitiesResult:
      type: object
      properties:
        data:
          type: object
          properties:
            kind:
              type: string
              enum: [order, address, booking, invoice]
            entities:
              type: object
              description: >-
                Only fields that passed this kind's own schema re-validation — never the raw
                provider JSON. Shape depends on `kind`: `order` -> `order_number` (required),
                `quantity` (optional); `address` -> `street`, `city` (required), `postal_code`,
                `country` (optional, ISO-2); `booking` -> `date` (YYYY-MM-DD), `time` (HH:MM,
                required), `service` (optional); `invoice` -> `invoice_number`, `amount`,
                `currency` (ISO-3, all required).
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    McpActionAuthorizeResult:
      type: object
      properties:
        data:
          type: object
          properties:
            decision:
              type: string
              enum: [allowed_free, allowed_charged]
              description: Only present on a `200` — a denial is a `402`/`403` error response, never this shape.
            charged_tokens:
              type: integer
              description: Tokens deducted for this action (0 when covered by the free monthly allowance).
            free_allowance:
              type: integer
              description: config('wacrm_mcp.free_actions_per_month') for this project's billing month.
            free_used:
              type: integer
              description: Free actions already used this calendar month, capped at free_allowance.

    AiScoreLeadResult:
      type: object
      properties:
        data:
          type: object
          properties:
            score:
              type: integer
              minimum: 0
              maximum: 100
            reason:
              type: string
              maxLength: 500
            next_step:
              type: string
              enum: [call_back, send_quote, schedule_meeting, nurture, disqualify, escalate_to_human]
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    AiTriageAlertResult:
      type: object
      properties:
        data:
          type: object
          properties:
            groups:
              type: array
              items:
                type: object
                properties:
                  priority:
                    type: string
                    enum: [critical, high, medium, low]
                  alert_ids:
                    type: array
                    items:
                      type: string
                    description: Each id is one of the `alerts[].id` values given in the request.
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    AiSuggestNextActionResult:
      type: object
      properties:
        data:
          type: object
          properties:
            action:
              type: string
              enum: [call_back, send_quote, move_stage, wait, escalate_to_human, other]
            reason:
              type: string
              maxLength: 500
            suggested_stage:
              type: [string, "null"]
              enum: [new, qualified, quote_sent, negotiation, won, lost, null]
            provider:
              type: string
              description: >-
                Which `AiProvider` driver answered — `mock` or `anthropic` —
                so a client can never mistake a mock response for a real one.
              example: anthropic
            tokens_deducted:
              type: integer

    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
            enum: [message.inbound, message.status]
        active:
          type: boolean
        created_at:
          type: string
          format: date-time

    WebhookEndpointCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEndpoint"

    WebhookEndpointCreate:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: Must be https:// and resolve to a public IP (SSRF guard, checked again at delivery time)
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum: [message.inbound, message.status]

    WebhookEndpointCreatedEnvelope:
      type: object
      properties:
        data:
          allOf:
            - $ref: "#/components/schemas/WebhookEndpoint"
            - type: object
              properties:
                secret:
                  type: string
                  description: Shown once, on create, only. Never returned again by any other response.

    ProductAnswerFact:
      type: object
      description: >-
        One product/variant this answer is allowed to cite. Same
        never-invented-price rule as `Product`/`ProductVariant`: `price`/
        `currency` are `null`, never guessed, when nothing was synced.
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        sku:
          type: [string, "null"]
        price:
          type: [string, "null"]
        currency:
          type: [string, "null"]

    ProductAnswerResult:
      type: object
      properties:
        data:
          type: object
          properties:
            answer:
              type: string
              description: >-
                Draft reply text. Never sent automatically — the agent-
                approval send path (job P20-j7) is what turns this into an
                actual WhatsApp message.
            products:
              type: array
              items:
                $ref: "#/components/schemas/ProductAnswerFact"
            grounded:
              type: boolean
              description: Whether at least one synced product matched the question.
            guard_triggered:
              type: boolean
              description: >-
                `true` when the AI reply stated a price/currency not present
                in `products` and the server replaced it with a safe
                template that names the matching products but states no
                price.
            provider:
              type: string
              example: mock
            tokens_deducted:
              type: integer

    PlanCheckoutRequest:
      type: object
      required: [plan]
      properties:
        plan:
          type: string
          enum: [starter, growth, pro, agency]

    PlanCheckoutEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            approval_url:
              type: string
              format: uri
              nullable: true
            subscription_id:
              type: string

    MetaPageCreate:
      type: object
      required: [channel, page_id, page_access_token]
      properties:
        channel:
          type: string
          enum: [messenger, instagram]
        page_id:
          type: string
        page_access_token:
          type: string
          description: Never stored in plaintext, never returned or logged.
        name:
          type: [string, "null"]
        ig_user_id:
          type: [string, "null"]
          description: The linked Instagram Professional account id, if any.

    MetaPage:
      type: object
      properties:
        id:
          type: string
          format: uuid
        channel:
          type: string
          enum: [messenger, instagram]
        page_id:
          type: string
        name:
          type: [string, "null"]
        ig_user_id:
          type: [string, "null"]
        status:
          type: string
          enum: [connecting, active, disconnected]

    MetaPageEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/MetaPage"

    MetaPageListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            meta_pages:
              type: array
              items:
                $ref: "#/components/schemas/MetaPage"

    BookingServiceCreate:
      type: object
      required: [name, duration_minutes]
      properties:
        name:
          type: string
          maxLength: 255
        duration_minutes:
          type: integer
          minimum: 1
          maximum: 1440
        price:
          type: [string, number, "null"]
        currency:
          type: [string, "null"]
          maxLength: 3
          minLength: 3
        active:
          type: boolean
          default: true

    BookingServiceUpdate:
      type: object
      properties:
        name:
          type: string
          maxLength: 255
        duration_minutes:
          type: integer
          minimum: 1
          maximum: 1440
        price:
          type: [string, number, "null"]
        currency:
          type: [string, "null"]
          maxLength: 3
          minLength: 3
        active:
          type: boolean

    BookingServiceResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        duration_minutes:
          type: integer
        price:
          type: [string, "null"]
        currency:
          type: [string, "null"]
        active:
          type: boolean
        created_at:
          type: string
          format: date-time

    BookingServiceEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/BookingServiceResource"

    BookingServiceCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/BookingServiceResource"

    AvailabilitySlot:
      type: object
      properties:
        staff_id:
          type: string
          format: uuid
        start_at:
          type: string
          format: date-time
        end_at:
          type: string
          format: date-time

    AvailabilitySlotCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/AvailabilitySlot"

    BookingCreate:
      type: object
      required: [service_id, contact_id, start_at]
      properties:
        service_id:
          type: string
          format: uuid
        staff_id:
          type: [string, "null"]
          format: uuid
        contact_id:
          type: string
          format: uuid
        start_at:
          type: string
          format: date-time
          description: UTC start time; `end_at` is always derived server-side from the service's `duration_minutes`.

    BookingReschedule:
      type: object
      required: [start_at]
      properties:
        start_at:
          type: string
          format: date-time

    BookingResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        service_id:
          type: string
          format: uuid
        staff_id:
          type: [string, "null"]
          format: uuid
        contact_id:
          type: string
          format: uuid
        start_at:
          type: string
          format: date-time
        end_at:
          type: string
          format: date-time
        status:
          type: string
          enum: [pending, confirmed, reminded, completed, no_show, cancelled]
        cancelled_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time

    BookingEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/BookingResource"

    BookingCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/BookingResource"

    CostCategoryBucket:
      type: object
      nullable: true
      description: "`null` when this category has no rollup data in the requested range — never `0`."
      properties:
        message_count:
          type: integer
        cost_estimate:
          type: number
          format: float

    CostForecastBucket:
      type: object
      nullable: true
      description: "`null` when this category has no rollup data in the trailing 30-day window — never `0`."
      properties:
        daily_average:
          type: number
          format: float
        next_30_days_estimate:
          type: number
          format: float
        estimate:
          type: boolean
          enum: [true]

    CostDailyRow:
      type: object
      properties:
        day:
          type: string
          format: date
        pricing_category:
          type: string
          enum: [marketing, utility, authentication, service]
        message_count:
          type: integer
        cost_estimate:
          type: number
          format: float

    CostReportEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            date_from:
              type: string
              format: date
            date_to:
              type: string
              format: date
            by_category:
              type: object
              properties:
                marketing:
                  $ref: "#/components/schemas/CostCategoryBucket"
                utility:
                  $ref: "#/components/schemas/CostCategoryBucket"
                authentication:
                  $ref: "#/components/schemas/CostCategoryBucket"
                service:
                  $ref: "#/components/schemas/CostCategoryBucket"
            daily:
              type: array
              items:
                $ref: "#/components/schemas/CostDailyRow"
            forecast:
              type: object
              properties:
                marketing:
                  $ref: "#/components/schemas/CostForecastBucket"
                utility:
                  $ref: "#/components/schemas/CostForecastBucket"
                authentication:
                  $ref: "#/components/schemas/CostForecastBucket"
                service:
                  $ref: "#/components/schemas/CostForecastBucket"

    SocialComment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        post_id:
          type: [string, "null"]
          format: uuid
        parent_comment_id:
          type: [string, "null"]
          format: uuid
        author_name:
          type: [string, "null"]
        body:
          type: [string, "null"]
        status:
          type: string
          enum: [visible, hidden, deleted]
        private_reply_sent_at:
          type: [string, "null"]
          format: date-time
        created_time:
          type: [string, "null"]
          format: date-time

    SocialCommentEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/SocialComment"

    SocialCommentCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/SocialComment"

    SocialCommentAgentAction:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tool:
          type: string
        risk:
          type: [string, "null"]
        policy_decision:
          type: string
          enum: [auto, needs_approval, denied]
        status:
          type: string
          enum: [proposed, rejected, executing, expired, succeeded, failed]

    SocialCommentReplyEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            comment:
              $ref: "#/components/schemas/SocialComment"
            agent_action:
              $ref: "#/components/schemas/SocialCommentAgentAction"

    ClientPortalLinkResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
        token:
          type: [string, "null"]
          description: Only present in the mint response; `null` on a revoke response.
        expires_at:
          type: [string, "null"]
          format: date-time
        revoked_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time

    ClientPortalLinkEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/ClientPortalLinkResource"

    ClientPortalTicket:
      type: object
      properties:
        id:
          type: string
          format: uuid
        subject:
          type: string
        status:
          type: string
          enum: [open, pending, closed]
        priority:
          type: string
          enum: [low, normal, high]
        type:
          type: string
          enum: [support, service_request]
        created_at:
          type: string
          format: date-time

    ClientPortalBooking:
      type: object
      properties:
        id:
          type: string
          format: uuid
        service_name:
          type: [string, "null"]
        start_at:
          type: string
          format: date-time
        end_at:
          type: string
          format: date-time
        status:
          type: string
          enum: [pending, confirmed, reminded, completed, no_show, cancelled]

    ClientPortalData:
      type: object
      description: >-
        Intentionally has no wallet/plan/billing field — see
        docs/ux/wacrm-ux-spec.md:377.
      properties:
        contact:
          type: object
          properties:
            name:
              type: [string, "null"]
        tickets:
          type: array
          items:
            $ref: "#/components/schemas/ClientPortalTicket"
        bookings:
          type: array
          items:
            $ref: "#/components/schemas/ClientPortalBooking"

    ClientPortalDataEnvelope:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/ClientPortalData"

    PublicBookingServiceResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        duration_minutes:
          type: integer
        price:
          type: [string, "null"]
        currency:
          type: [string, "null"]

    PublicBookingServicesEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            store_name:
              type: [string, "null"]
            powered_by_badge:
              type: boolean
            services:
              type: array
              items:
                $ref: "#/components/schemas/PublicBookingServiceResource"

    PublicBookingCreate:
      type: object
      required: [service_id, start_at, contact]
      properties:
        service_id:
          type: string
          format: uuid
        staff_id:
          type: [string, "null"]
          format: uuid
        start_at:
          type: string
          format: date-time
        contact:
          type: object
          required: [phone]
          properties:
            phone:
              type: string
              maxLength: 32
            name:
              type: [string, "null"]
              maxLength: 255

    CareAlertCreate:
      type: object
      required: [route_key, template]
      properties:
        route_key:
          type: string
          maxLength: 64
        template:
          type: string
          maxLength: 255
          description: Name of an already-`approved` `wacrm_care_*` template for this project.
        params:
          type: array
          items: {}
          description: Positional body placeholders, same shape as POST /v1/messages (type=template).
        image_url:
          type: [string, "null"]
          maxLength: 2048
          description: >-
            Optional. Never inlined without a recorded `opted_in` consent row
            for the recipient (R-CARE-06) — refused with `422
            image_consent_required` when consent is missing, nothing sent.

    CareAlertEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            status:
              type: string
              enum: [sent]
            external_id:
              type: string
            recipient_contact_id:
              type: string
              format: uuid
            template:
              type: string
            link_expires_at:
              type: string
              format: date-time

    CareAlertAckEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            status:
              type: string
              enum: [acknowledged, already_acknowledged]
            acked_at:
              type: string
              format: date-time
