openapi: 3.1.0
info:
  title: Hydra Public REST API
  version: "1.0.0"
  summary: Versioned REST API for Hydra workspace data — leads, contacts, accounts, conversations, tickets.
  description: |
    The public API at `/api/v1/*` exposes core Hydra entities to partner
    integrations (Zapier, n8n, Make, custom backends). It uses the same
    bearer-token system, tenant scoping, audit log, and rate limiter as
    Hydra's MCP surface, but writes are **vanilla REST** — direct `POST`,
    `PATCH`, `DELETE` calls without the LLM-tool preview/confirm dance.

    For LLM-tool callers that benefit from a preview-then-confirm safety
    net, the parallel `/api/mcp/v1/*` surface is documented separately.

    Mint API keys at **Settings → API Access** in the Hydra dashboard.
    Each key carries a set of scopes that gate which endpoints it can call.
  contact:
    name: Hydra Support
    email: support@hydra-help.com
    url: https://hydra-help.com/help
  license:
    name: Proprietary
servers:
  - url: https://hydra-one-tau.vercel.app
    description: Production API host

security:
  - bearerAuth: []

tags:
  - name: Leads
    description: Customers with `type='lead'` — captured by widget, manual entry, or import.
  - name: Contacts
    description: Customers with `type='contact'` — must be attached to an account.
  - name: Accounts
    description: Companies / organizations that contain contacts.
  - name: Conversations
    description: Inbox threads (widget chats, inbound emails). Read + lightweight update only.
  - name: Tickets
    description: Support tickets, optionally linked to a customer and/or conversation.

paths:
  # ─── Leads ──────────────────────────────────────────────────────────────
  /api/v1/leads:
    get:
      tags: [Leads]
      summary: List leads
      operationId: listLeads
      security:
        - bearerAuth: [crm:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: List of leads, newest first.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [leads, count]
                properties:
                  leads:
                    type: array
                    items:
                      $ref: "#/components/schemas/Lead"
                  count:
                    type: integer
                    description: Number of leads returned in this response (≤ limit).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Leads]
      summary: Create a lead
      operationId: createLead
      security:
        - bearerAuth: [crm:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadCreate"
            examples:
              minimal:
                summary: Email only
                value: { email: "alice@example.com" }
              full:
                summary: All optional fields
                value:
                  email: "alice@example.com"
                  name: "Alice Example"
                  phone: "+15551234"
                  company: "Example Co"
                  source: "zapier"
      responses:
        "201":
          description: Lead created. Fires `customer.created` flow event with `source='public_api'`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  lead: { $ref: "#/components/schemas/Lead" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/leads/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Leads]
      summary: Fetch a lead
      operationId: getLead
      security:
        - bearerAuth: [crm:read]
      responses:
        "200":
          description: Lead found.
          content:
            application/json:
              schema:
                type: object
                properties:
                  lead: { $ref: "#/components/schemas/Lead" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Leads]
      summary: Update a lead
      operationId: updateLead
      security:
        - bearerAuth: [crm:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadUpdate"
      responses:
        "200":
          description: Lead updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  lead: { $ref: "#/components/schemas/Lead" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Leads]
      summary: Delete a lead
      operationId: deleteLead
      security:
        - bearerAuth: [crm:write]
      responses:
        "204":
          description: Lead deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── Contacts ───────────────────────────────────────────────────────────
  /api/v1/contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      operationId: listContacts
      security:
        - bearerAuth: [crm:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - name: account_id
          in: query
          schema: { type: string, format: uuid }
          description: Filter to contacts attached to a specific account.
      responses:
        "200":
          description: List of contacts, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [contacts, count]
                properties:
                  contacts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Contact"
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Contacts]
      summary: Create a contact
      description: |
        Both `email` and `account_id` are required — the
        `customers_contact_requires_account` DB CHECK rejects contacts
        without an account. `account_id` must reference an account in
        your tenant.
      operationId: createContact
      security:
        - bearerAuth: [crm:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactCreate"
      responses:
        "201":
          description: Contact created. Records two `lifecycle_events` rows (`created` + `account_linked`) and fires `customer.created` flow event.
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/contacts/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Contacts]
      summary: Fetch a contact
      operationId: getContact
      security:
        - bearerAuth: [crm:read]
      responses:
        "200":
          description: Contact found.
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact: { $ref: "#/components/schemas/Contact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Contacts]
      summary: Update a contact
      description: |
        `account_id` cannot be set to `null` on a contact (the CHECK
        constraint would orphan the row to an invalid state). Move the
        contact to a different account by passing a new UUID, or
        demote it to a lead by deleting and re-creating with `type='lead'`.
      operationId: updateContact
      security:
        - bearerAuth: [crm:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactUpdate"
      responses:
        "200":
          description: Contact updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Contacts]
      summary: Delete a contact
      operationId: deleteContact
      security:
        - bearerAuth: [crm:write]
      responses:
        "204":
          description: Contact deleted. Prior conversations are retained (FK is `ON DELETE SET NULL`).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── Accounts ───────────────────────────────────────────────────────────
  /api/v1/accounts:
    get:
      tags: [Accounts]
      summary: List accounts
      operationId: listAccounts
      security:
        - bearerAuth: [crm:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - name: lifecycle_stage
          in: query
          schema:
            $ref: "#/components/schemas/LifecycleStage"
          description: Filter to accounts in a specific lifecycle stage.
      responses:
        "200":
          description: List of accounts, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [accounts, count]
                properties:
                  accounts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Account"
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Accounts]
      summary: Create an account
      operationId: createAccount
      security:
        - bearerAuth: [crm:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccountCreate"
      responses:
        "201":
          description: Account created. Fires `account.created` flow event.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/accounts/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Accounts]
      summary: Fetch an account
      operationId: getAccount
      security:
        - bearerAuth: [crm:read]
      responses:
        "200":
          description: Account found.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account: { $ref: "#/components/schemas/Account" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Accounts]
      summary: Update an account
      description: |
        Changing `lifecycle_stage` records a `lifecycle_events` row,
        fires `account.stage_changed`, and additionally fires
        `account.went_at_risk` on transitions into `at_risk`.
      operationId: updateAccount
      security:
        - bearerAuth: [crm:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccountUpdate"
      responses:
        "200":
          description: Account updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Accounts]
      summary: Delete an account
      description: |
        Returns `409 fk_blocked` if any contact references this account —
        the `customers_contact_requires_account` CHECK would orphan those
        contacts to an invalid state. Reassign or delete contacts first,
        or prefer `PATCH lifecycle_stage='churned'` for normal lifecycle
        transitions.
      operationId: deleteAccount
      security:
        - bearerAuth: [crm:write]
      responses:
        "204":
          description: Account deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Contacts are still attached to this account.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - type: object
                    properties:
                      code:
                        type: string
                        const: fk_blocked
                      blockers:
                        type: object
                        properties:
                          contacts:
                            type: integer
                            description: Count of contacts attached to this account.

  # ─── Conversations ──────────────────────────────────────────────────────
  /api/v1/conversations:
    get:
      tags: [Conversations]
      summary: List conversations
      description: |
        Read-only at this collection level. Conversations are created
        through the widget JS embed or inbound email — there's no `POST`
        shape that makes sense for partner integrations.
      operationId: listConversations
      security:
        - bearerAuth: [conversations:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/ConversationStatus"
        - name: channel_id
          in: query
          schema: { type: string, format: uuid }
        - name: assigned_agent_id
          in: query
          schema: { type: string, format: uuid }
        - name: customer_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: List of conversations, most-recently-updated first.
          content:
            application/json:
              schema:
                type: object
                required: [conversations, count]
                properties:
                  conversations:
                    type: array
                    items: { $ref: "#/components/schemas/Conversation" }
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/conversations/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Conversations]
      summary: Fetch a conversation
      operationId: getConversation
      security:
        - bearerAuth: [conversations:read]
      responses:
        "200":
          description: Conversation found.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation: { $ref: "#/components/schemas/Conversation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Conversations]
      summary: Assign or close a conversation
      description: |
        Only two write paths are exposed:

        1. **Assign / unassign** — set `assigned_agent_id` to a UUID
           (must be an admin user in your tenant) or `null` to unassign.
           If the conversation is currently `open`, status auto-promotes
           to `assigned`.
        2. **Close** — set `status` to `resolved` or `escalated`. On
           the open→resolved edge, Hydra stamps `resolved_at` + SLA
           resolution, runs the conversation summarizer, and dispatches
           the CSAT survey (if your tenant has CSAT enabled).

        Reopening, manual `waiting` transitions, and other admin-only
        paths are intentionally not exposed — use the dashboard.
      operationId: updateConversation
      security:
        - bearerAuth: [conversations:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConversationUpdate"
      responses:
        "200":
          description: Conversation updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation: { $ref: "#/components/schemas/Conversation" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/conversations/{id}/replies:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Conversations]
      summary: List replies on a conversation
      description: |
        Replies are returned in transcript order (oldest first). Internal
        notes are included — filter on the `is_internal` field client-side
        if you need to exclude them.
      operationId: listConversationReplies
      security:
        - bearerAuth: [conversations:read]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: List of replies, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [replies, count]
                properties:
                  replies:
                    type: array
                    items: { $ref: "#/components/schemas/ConversationReply" }
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Conversations]
      summary: Send a reply on a conversation
      description: |
        Posts a reply to the conversation thread. The reply is attributed
        to the admin who minted the API key (`mcp_api_keys.created_by_admin_id`).

        **Channel routing:**
        - **Widget channel** (default) — reply persists; the customer
          sees it via the widget's existing Realtime / polling path.
        - **Email channel + customer-visible reply** — returns
          `400 validation_error` with an explanation. Email-channel
          replies need RFC 5322 threading + Resend send machinery that
          the public API doesn't expose yet. Use the dashboard for now.
        - **Email channel + internal note** — accepted (internal notes
          are never customer-facing, so the email-routing constraint
          doesn't apply).

        **Side effects:**
        - Bumps `conversations.updated_at` for inbox reordering.
        - On the first non-internal reply, stamps SLA `first_response_at`.
      operationId: createConversationReply
      security:
        - bearerAuth: [conversations:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReplyCreate"
            examples:
              public_reply:
                summary: Customer-visible reply (widget channel)
                value:
                  body: "Thanks for reaching out — we're on it!"
              internal_note:
                summary: Private note (any channel)
                value:
                  body: "Customer is on the Pro plan, escalate to founders."
                  is_internal: true
      responses:
        "201":
          description: Reply created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  reply: { $ref: "#/components/schemas/ConversationReply" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ─── Tickets ────────────────────────────────────────────────────────────
  /api/v1/tickets:
    get:
      tags: [Tickets]
      summary: List tickets
      operationId: listTickets
      security:
        - bearerAuth: [tickets:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/TicketStatus" }
        - name: priority
          in: query
          schema: { $ref: "#/components/schemas/TicketPriority" }
        - name: assigned_to
          in: query
          schema: { type: string, format: uuid }
        - name: customer_id
          in: query
          schema: { type: string, format: uuid }
        - name: conversation_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: List of tickets, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [tickets, count]
                properties:
                  tickets:
                    type: array
                    items: { $ref: "#/components/schemas/Ticket" }
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Tickets]
      summary: Create a ticket
      description: |
        `customer_id`, `conversation_id`, and `assigned_to` (when
        non-null) must reference rows in your tenant — checked before
        insert. When `customer_id` is set, the customer is emailed the
        ticket-received template (same as inbox-created tickets).
      operationId: createTicket
      security:
        - bearerAuth: [tickets:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TicketCreate"
      responses:
        "201":
          description: Ticket created. Fires `ticket.created` flow event.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket: { $ref: "#/components/schemas/Ticket" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/tickets/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Tickets]
      summary: Fetch a ticket
      operationId: getTicket
      security:
        - bearerAuth: [tickets:read]
      responses:
        "200":
          description: Ticket found.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket: { $ref: "#/components/schemas/Ticket" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Tickets]
      summary: Update a ticket
      description: |
        Status and assignment changes fire flow events:
        - status → `resolved`: `ticket.resolved` + SLA resolution stamp.
        - any other status change: `ticket.status_changed`.
        - assigned_to → new non-null agent: `ticket.assigned` + the
          new assignee is emailed.
      operationId: updateTicket
      security:
        - bearerAuth: [tickets:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TicketUpdate"
      responses:
        "200":
          description: Ticket updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket: { $ref: "#/components/schemas/Ticket" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Tickets]
      summary: Delete a ticket
      operationId: deleteTicket
      security:
        - bearerAuth: [tickets:write]
      responses:
        "204":
          description: Ticket deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenScope" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: hmcp_xxxxxxxxxxxxx
      description: |
        Bearer token minted at **Settings → API Access**. Token format
        `hmcp_<base64url-32>` — same key system as Hydra's MCP surface.
        Each key carries a set of scopes that gate which endpoints it
        can call. See [the public-API help article](https://hydra-help.com/docs/api)
        for scope semantics.

  parameters:
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
      description: Max rows to return (1–100).
    IdPath:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Resource UUID.

  headers:
    RateLimitLimit:
      schema: { type: integer }
      description: Per-key cap (60 rpm in v1).
    RateLimitRemaining:
      schema: { type: integer }
      description: Remaining requests in the current 60-second window.
    RetryAfter:
      schema: { type: integer }
      description: Seconds to wait before retrying.

  responses:
    Unauthorized:
      description: Missing, malformed, or revoked bearer token.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ErrorResponse"
              - type: object
                properties:
                  code:
                    type: string
                    const: unauthorized
    ForbiddenScope:
      description: Token is valid but lacks the required scope for this route.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ErrorResponse"
              - type: object
                properties:
                  code:
                    type: string
                    const: forbidden_scope
    ValidationError:
      description: Body or query parameter failed validation.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ErrorResponse"
              - type: object
                properties:
                  code:
                    type: string
                    const: validation_error
    NotFound:
      description: Resource doesn't exist or doesn't belong to your tenant.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ErrorResponse"
              - type: object
                properties:
                  code:
                    type: string
                    const: not_found
    RateLimited:
      description: Per-key rate cap exceeded. Wait `Retry-After` seconds.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        X-RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        X-RateLimit-Remaining:
          schema: { type: integer, const: 0 }
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ErrorResponse"
              - type: object
                properties:
                  code:
                    type: string
                    const: rate_limited

  schemas:
    ErrorResponse:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
          description: Human-readable message; do not branch on this — branch on `code`.
        code:
          type: string
          description: Stable machine-readable code.
          enum:
            - unauthorized
            - forbidden_scope
            - validation_error
            - not_found
            - rate_limited
            - fk_blocked
            - server_error

    LifecycleStage:
      type: string
      enum: [prospect, active, at_risk, churned]
    RenewalStatus:
      type: string
      nullable: true
      enum: [upcoming, at_risk, renewed, churned, null]
    ConversationStatus:
      type: string
      enum: [open, assigned, waiting, resolved, escalated]
    TicketStatus:
      type: string
      enum: [open, in_progress, resolved, closed]
    TicketPriority:
      type: string
      enum: [low, normal, high, urgent]

    Lead:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        email: { type: string, nullable: true }
        name: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        company: { type: string, nullable: true }
        source: { type: string, nullable: true }
        source_bot_id: { type: string, format: uuid, nullable: true }
        source_conversation_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
    LeadCreate:
      type: object
      description: At least one field is required.
      properties:
        email: { type: string, nullable: true }
        name: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        company: { type: string, nullable: true }
        source: { type: string, nullable: true }
    LeadUpdate:
      $ref: "#/components/schemas/LeadCreate"

    Contact:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        account_id: { type: string, format: uuid }
        email: { type: string, nullable: true }
        name: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        company: { type: string, nullable: true }
        source: { type: string, nullable: true }
        source_bot_id: { type: string, format: uuid, nullable: true }
        source_conversation_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
    ContactCreate:
      type: object
      required: [email, account_id]
      properties:
        email: { type: string }
        account_id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        company: { type: string, nullable: true }
        source: { type: string, nullable: true }
    ContactUpdate:
      type: object
      description: At least one field is required. `account_id` cannot be `null` (CHECK constraint).
      properties:
        email: { type: string, nullable: true }
        account_id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        company: { type: string, nullable: true }
        source: { type: string, nullable: true }

    Account:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        domain: { type: string, nullable: true }
        industry: { type: string, nullable: true }
        website: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        lifecycle_stage: { $ref: "#/components/schemas/LifecycleStage" }
        health_score:
          type: integer
          nullable: true
          minimum: 0
          maximum: 100
        renewal_date:
          type: string
          format: date
          nullable: true
        renewal_status: { $ref: "#/components/schemas/RenewalStatus" }
        mrr_cents:
          type: integer
          nullable: true
          description: Monthly recurring revenue in cents ($1,000.00 = 100000).
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    AccountCreate:
      type: object
      required: [name]
      properties:
        name: { type: string }
        domain: { type: string, nullable: true }
        industry: { type: string, nullable: true }
        website: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        lifecycle_stage: { $ref: "#/components/schemas/LifecycleStage" }
        health_score: { type: integer, nullable: true, minimum: 0, maximum: 100 }
        renewal_date: { type: string, format: date, nullable: true }
        renewal_status: { $ref: "#/components/schemas/RenewalStatus" }
        mrr_cents: { type: integer, nullable: true }
    AccountUpdate:
      $ref: "#/components/schemas/AccountCreate"

    Conversation:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/ConversationStatus" }
        subject: { type: string, nullable: true }
        summary: { type: string, nullable: true }
        channel_id: { type: string, format: uuid, nullable: true }
        assigned_agent_id: { type: string, format: uuid, nullable: true }
        customer_id: { type: string, format: uuid, nullable: true }
        bot_id: { type: string, format: uuid, nullable: true }
        widget_id: { type: string, format: uuid, nullable: true }
        intent: { type: string, nullable: true }
        sentiment:
          type: string
          nullable: true
          enum: [positive, neutral, negative, frustrated, null]
        language: { type: string, nullable: true }
        resolved_at: { type: string, format: date-time, nullable: true }
        handoff_at: { type: string, format: date-time, nullable: true }
        handoff_reason: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ConversationUpdate:
      type: object
      description: At least one field is required.
      properties:
        status:
          type: string
          enum: [resolved, escalated]
          description: Only close transitions are exposed via the API.
        assigned_agent_id:
          type: string
          format: uuid
          nullable: true

    ConversationReply:
      type: object
      properties:
        id: { type: string, format: uuid }
        conversation_id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        author_id:
          type: string
          format: uuid
          description: admin_users.id of the admin who posted the reply (or who minted the API key, for public-API replies).
        content: { type: string }
        is_internal:
          type: boolean
          description: Internal notes are visible to admins only — never to the customer.
        created_at: { type: string, format: date-time }
    ReplyCreate:
      type: object
      required: [body]
      properties:
        body:
          type: string
          description: Reply content. Required, non-empty after trim.
        is_internal:
          type: boolean
          default: false
          description: Set `true` to post a private internal note instead of a customer-visible reply.

    Ticket:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        conversation_id: { type: string, format: uuid, nullable: true }
        customer_id: { type: string, format: uuid, nullable: true }
        title: { type: string }
        description: { type: string }
        status: { $ref: "#/components/schemas/TicketStatus" }
        priority: { $ref: "#/components/schemas/TicketPriority" }
        assigned_to: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TicketCreate:
      type: object
      required: [title]
      properties:
        title: { type: string }
        description: { type: string, nullable: true }
        priority: { $ref: "#/components/schemas/TicketPriority" }
        customer_id: { type: string, format: uuid, nullable: true }
        conversation_id: { type: string, format: uuid, nullable: true }
        assigned_to: { type: string, format: uuid, nullable: true }
    TicketUpdate:
      type: object
      description: At least one field is required.
      properties:
        title: { type: string }
        description: { type: string, nullable: true }
        status: { $ref: "#/components/schemas/TicketStatus" }
        priority: { $ref: "#/components/schemas/TicketPriority" }
        assigned_to: { type: string, format: uuid, nullable: true }
