openapi: "3.1.0"

info:
  title: "Echo — Omnichannel Communications API"
  version: "0.2.0"
  description: |
    Echo is the APEX Suite omnichannel communications engine. One send call reaches your
    recipients over WhatsApp, Messenger, Instagram, Telegram, SMS, email, or conversational
    forms; every message from every channel lands in one unified message store with
    conversation threading, contact identity resolution, reusable templates, and
    chat-delivered forms.

    **Authentication.** Every request carries a single `Authorization: Bearer` header holding
    either a machine-to-machine access token or a platform-issued tenant API key. Your tenant
    identifier travels as a path segment on tenant-scoped routes (and as the `tenant_id` body
    field on `POST /send`) and is validated against your credential: a mismatch is rejected
    with `403`, an unknown tenant with `404`.

    **Delivery is asynchronous.** `POST /send` validates, routes, and queues the message, then
    returns immediately with `status: "queued"`. The terminal delivery state reaches you two
    ways: poll `GET /messages/{tenant_id}/{message_id}`, or receive the `echo.status_update`
    webhook if your tenant is configured for webhook forwarding (see the `webhooks` section).
    A message that fails after all retries and channel fallbacks appears with status `FAILED`
    and an `error` value prefixed `DEAD_LETTER:`.

    **Response envelope.** Messages, templates, forms, widget, and search routes wrap their
    payload in the standard envelope `{ok, data, error, meta}`. `POST /send`, `GET /health`,
    and the contact routes return their documented body directly, without the envelope. Each
    operation below states which shape it returns.

    **Rate limiting.** Requests are throttled at 120 per minute per credential. Rejected
    requests return `429` with a `Retry-After` header and an envelope-shaped error body.

servers:
  - url: https://echo.dev.apex.reisiger.org
    description: Development
  - url: http://localhost:9160
    description: Local

security:
  - bearerAuth: []

tags:
  - name: Send
    description: >-
      Queue an outbound message on any configured channel. The flagship operation —
      delivery itself is asynchronous.
  - name: Messages
    description: >-
      Unified message store — conversation threads, delivery-status polling, statistics,
      and captured inbound media.
  - name: Contacts
    description: >-
      Contact identity graph — one contact per person across channels, with identifier
      management, merge suggestions, and reply-quota session inspection.
  - name: Templates
    description: >-
      Reusable message templates with per-channel renderings and the provider approval
      flow required for WhatsApp business-initiated messaging.
  - name: Forms
    description: >-
      Conversational forms delivered over chat channels — definitions, live sessions,
      and completed submissions.
  - name: Widgets
    description: Tenant-scoped dashboard data — delivery metrics, channel health, recent activity.
  - name: Search
    description: Federated search across contacts, templates, and messages.
  - name: Service
    description: Service liveness.

paths:
  /send:
    post:
      operationId: sendMessage
      tags: [Send]
      summary: Queue an outbound message
      x-apex-availability: available
      description: |
        Validates the request, resolves the recipient's contact identity, checks consent,
        selects the delivery channel, renders any referenced template, and queues the message
        for delivery. Returns **raw JSON** (no envelope) with `status: "queued"`.

        **This call does not wait for delivery.** Poll
        `GET /messages/{tenant_id}/{message_id}` with the returned `message_id`, or receive
        the `echo.status_update` webhook, to learn the terminal state. A message that
        exhausts all retries and channel fallbacks surfaces as status `FAILED` with an
        `error` prefixed `DEAD_LETTER:`.

        Channel selection: pass `channel` to target one channel explicitly, or omit it and
        the routing engine picks the best configured channel for the recipient. For email,
        the subject line rides in `metadata.subject`; the sending address is provisioned
        per tenant by the platform operator and cannot be set per request.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendRequest"
            examples:
              textOverWhatsApp:
                summary: Plain text to a phone number over WhatsApp
                value:
                  tenant_id: your-tenant
                  channel: whatsapp
                  to: "+27821234567"
                  content:
                    type: text
                    text: "Your delivery is scheduled for tomorrow between 09:00 and 12:00."
              routedEmailWithSubject:
                summary: Email with subject in metadata; routing engine picks the channel
                value:
                  tenant_id: your-tenant
                  to: "recipient@example.com"
                  content:
                    type: text
                    text: "Your statement for July is attached to your account portal."
                  metadata:
                    subject: "Your July statement is ready"
                  category: utility
              approvedTemplate:
                summary: Send an approved template with variables
                value:
                  tenant_id: your-tenant
                  channel: whatsapp
                  to: "+27821234567"
                  content:
                    type: text
                    text: ""
                  template_name: appointment_reminder
                  template_variables:
                    name: "Thandi"
                    date: "14 August"
                  locale: en
      responses:
        "200":
          description: Message accepted and queued for asynchronous delivery. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendResponse"
              examples:
                queued:
                  value:
                    task_id: "8f14e45f-ceea-4e57-8f3a-2d9c1b7a6f21"
                    message_id: "d3b07384-d9a0-4c7e-9a6e-5b1a2c3d4e5f"
                    status: queued
                    routed_channel: whatsapp
        "400":
          description: The requested channel is not configured for this tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The credential's tenant does not match `tenant_id`, or the recipient has not
            consented to receive this category of message.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "404":
          description: Unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "409":
          description: >-
            Reply quota exceeded — the per-contact service-window reply allowance for this
            channel is used up.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "422":
          description: >-
            Routing failed (no viable channel), the recipient has no identifier for the
            requested channel, or the request body failed validation.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/HttpError"
                  - $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimited"

  /messages/{tenant_id}/conversations:
    get:
      operationId: listConversations
      tags: [Messages]
      summary: List conversations grouped by contact
      x-apex-availability: available
      description: >-
        The inbox view: one entry per contact with the latest message, unread count, and
        total message count, newest first. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
          description: Maximum conversations to return.
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
          description: Pagination offset.
      responses:
        "200":
          description: Conversation summaries, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          conversations:
                            type: array
                            items:
                              $ref: "#/components/schemas/ConversationSummary"
                          total:
                            type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /messages/{tenant_id}/conversations/{contact_id}:
    get:
      operationId: getConversationThread
      tags: [Messages]
      summary: Get the full message thread for a contact
      x-apex-availability: available
      description: >-
        Every message exchanged with one contact across all channels, in chronological
        order (oldest first). Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/ContactId"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
          description: Maximum messages to return.
      responses:
        "200":
          description: The message thread, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          contact_id: { type: string }
                          messages:
                            type: array
                            items:
                              $ref: "#/components/schemas/MessageRecord"
                          total: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /messages/{tenant_id}/stats:
    get:
      operationId: getMessageStats
      tags: [Messages]
      summary: Get message statistics
      x-apex-availability: available
      description: >-
        Message counts for the tenant broken down by channel, direction, and status.
        Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      responses:
        "200":
          description: Statistics, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        description: Counts keyed by channel, direction, and status.
                        properties:
                          total: { type: integer }
                          by_channel:
                            type: object
                            additionalProperties: { type: integer }
                          by_direction:
                            type: object
                            additionalProperties: { type: integer }
                          by_status:
                            type: object
                            additionalProperties: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /messages/{tenant_id}/media/{channel}/{media_id}:
    get:
      operationId: getInboundMedia
      tags: [Messages]
      summary: Retrieve captured inbound media
      x-apex-availability: available
      description: |
        Serves media (images, documents) received from your contacts. Provider media URLs
        expire quickly — WhatsApp media is retrievable from the provider for only around
        five minutes — so Echo captures inbound media at webhook receipt and serves it from
        its own store. Default retention is 24 hours; fetch and persist media promptly,
        then burn Echo's copy with the DELETE operation on this path.

        Returns the raw binary with its original content type and, when known, an inline
        `Content-Disposition` filename. If the stored copy is absent, Echo attempts one
        best-effort on-demand fetch from the provider (WhatsApp only, works only inside the
        provider's retrieval window) before returning `404`.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/MediaChannel"
        - $ref: "#/components/parameters/MediaId"
      responses:
        "200":
          description: The media binary.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Media not available (never captured, expired, or unknown tenant).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: deleteInboundMedia
      tags: [Messages]
      summary: Burn captured inbound media
      x-apex-availability: available
      description: |
        Data-minimisation "burn after acknowledge": once your system has fetched and
        persisted the media, delete Echo's stored copy. With `purge_provider=true` the
        provider's copy (Meta) is deleted as well. If you never call this, the retention
        time-to-live cleans up automatically. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/MediaChannel"
        - $ref: "#/components/parameters/MediaId"
        - name: purge_provider
          in: query
          schema: { type: boolean, default: false }
          description: Also delete the media from the originating provider.
      responses:
        "200":
          description: Deletion result, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          deleted:
                            type: boolean
                            description: Whether a stored copy existed and was removed.
                          provider_deleted:
                            type: [boolean, "null"]
                            description: Provider-side deletion result; null when not attempted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /messages/{tenant_id}:
    get:
      operationId: listMessages
      tags: [Messages]
      summary: List messages
      x-apex-availability: available
      description: >-
        Flat message listing for the tenant with optional filters, newest first.
        Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - name: channel
          in: query
          schema:
            $ref: "#/components/schemas/Channel"
          description: Filter by channel.
        - name: direction
          in: query
          schema:
            type: string
            enum: [inbound, outbound]
          description: Filter by direction.
        - name: contact_id
          in: query
          schema: { type: string }
          description: Filter by resolved contact.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Messages, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          messages:
                            type: array
                            items:
                              $ref: "#/components/schemas/MessageRecord"
                          total: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /messages/{tenant_id}/{message_id}:
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get one message — the delivery-status poll
      x-apex-availability: available
      description: |
        Fetch a single message record by the `message_id` returned from `POST /send`.
        This is the polling half of the asynchronous delivery model: the `status` field
        advances as the worker and provider receipts progress, and a message that failed
        after all retries and channel fallbacks shows status `FAILED` with an `error`
        prefixed `DEAD_LETTER:`. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - name: message_id
          in: path
          required: true
          schema: { type: string }
          description: The message identifier returned by `POST /send`.
      responses:
        "200":
          description: The message record, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/MessageRecord"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Message not found for this tenant, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}:
    get:
      operationId: listContacts
      tags: [Contacts]
      summary: List contacts
      x-apex-availability: available
      description: >-
        List the tenant's contacts with their linked channel identifiers. Contact routes
        return raw JSON (no envelope).
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Contacts. Raw JSON array, no envelope.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/lookup:
    get:
      operationId: lookupContact
      tags: [Contacts]
      summary: Look up a contact by identifier
      x-apex-availability: available
      description: >-
        Resolve a contact by one of its channel identifiers without creating anything.
        Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - name: identifier_type
          in: query
          required: true
          schema:
            $ref: "#/components/schemas/IdentifierType"
        - name: value
          in: query
          required: true
          schema: { type: string }
          description: The identifier value, e.g. a phone number or email address.
      responses:
        "200":
          description: The matching contact. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: No contact holds this identifier, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/pending-links:
    get:
      operationId: listPendingLinks
      tags: [Contacts]
      summary: List pending identity-merge suggestions
      x-apex-availability: available
      description: >-
        Suggested merges between contacts that appear to be the same person, awaiting
        approval. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      responses:
        "200":
          description: Pending links. Raw JSON array, no envelope.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/PendingLink"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/pending-links/{link_id}/approve:
    post:
      operationId: approvePendingLink
      tags: [Contacts]
      summary: Approve a pending merge link
      x-apex-availability: available
      description: >-
        Approving a link executes the contact merge and returns the surviving contact.
        Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/LinkId"
        - name: actor
          in: query
          schema: { type: string, default: admin }
          description: Recorded in the merge audit log as the approver.
      responses:
        "200":
          description: The merged contact. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Link not found or no longer pending, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/pending-links/{link_id}/reject:
    post:
      operationId: rejectPendingLink
      tags: [Contacts]
      summary: Reject a pending merge link
      x-apex-availability: available
      description: Dismiss a merge suggestion. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/LinkId"
        - name: actor
          in: query
          schema: { type: string, default: admin }
          description: Recorded as the rejecting actor.
      responses:
        "200":
          description: Rejection confirmation. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    const: rejected
                  link_id: { type: string }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Link not found or no longer pending, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/merge-log:
    get:
      operationId: getMergeLog
      tags: [Contacts]
      summary: View the merge audit trail
      x-apex-availability: available
      description: >-
        The full audit trail of contact merge and split operations, including trigger,
        confidence, and actor. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      responses:
        "200":
          description: Merge log entries. Raw JSON array, no envelope.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/MergeLogEntry"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/stats:
    get:
      operationId: getContactStats
      tags: [Contacts]
      summary: Get contact store statistics
      x-apex-availability: available
      description: Counts of contacts, identifiers, pending links, and merge operations. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      responses:
        "200":
          description: Statistics. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id: { type: string }
                  contacts: { type: integer }
                  identifiers: { type: integer }
                  pending_links: { type: integer }
                  merge_operations: { type: integer }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/merge:
    post:
      operationId: mergeContacts
      tags: [Contacts]
      summary: Merge two contacts
      x-apex-availability: available
      description: >-
        Explicitly merge two contacts. The secondary contact's identifiers and history
        move onto the primary contact. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [primary_contact_id, secondary_contact_id]
              properties:
                primary_contact_id:
                  type: string
                  description: The contact that survives the merge.
                secondary_contact_id:
                  type: string
                  description: The contact folded into the primary.
            examples:
              merge:
                value:
                  primary_contact_id: "c-1f2e3d4c"
                  secondary_contact_id: "c-5b6a7c8d"
      responses:
        "200":
          description: The merged contact. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          description: Attempt to merge a contact with itself, or contact graph not enabled for the tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: One or both contacts not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/{contact_id}:
    get:
      operationId: getContact
      tags: [Contacts]
      summary: Get a contact
      x-apex-availability: available
      description: Fetch one contact with its channel identifiers. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/ContactId"
      responses:
        "200":
          description: The contact. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Contact not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      operationId: updateContact
      tags: [Contacts]
      summary: Update a contact
      x-apex-availability: available
      description: Update a contact's display name or metadata. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name:
                  type: [string, "null"]
                metadata:
                  type: [object, "null"]
                  additionalProperties: true
            examples:
              rename:
                value:
                  display_name: "Thandi Nkosi"
      responses:
        "200":
          description: The updated contact. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Contact not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/{contact_id}/sessions:
    get:
      operationId: listContactSessions
      tags: [Contacts]
      summary: Inspect reply-quota sessions for a contact
      x-apex-availability: available
      description: |
        Every reply-quota session recorded for the contact across all channels — open,
        expired, and closed. Use this to know how many replies remain inside the
        contact's service window before attempting a send (a send over quota returns
        `409`). Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/ContactId"
      responses:
        "200":
          description: Reply-quota sessions. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact_id: { type: string }
                  tenant_id: { type: string }
                  reply_quota_enabled: { type: boolean }
                  sessions:
                    type: array
                    items:
                      $ref: "#/components/schemas/ReplyQuotaSession"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /contacts/{tenant_id}/{contact_id}/identifiers:
    post:
      operationId: addContactIdentifier
      tags: [Contacts]
      summary: Add an identifier to a contact
      x-apex-availability: available
      description: >-
        Attach an additional channel identifier (phone, email, and so on) to an existing
        contact. If another contact already holds the identifier, the call fails with
        `409` and the conflicting contact's id. Raw JSON response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/ContactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier_type, value]
              properties:
                identifier_type:
                  $ref: "#/components/schemas/IdentifierType"
                value:
                  type: string
                channel:
                  type: [string, "null"]
                  description: Channel to associate; defaults to the identifier type.
            examples:
              addEmail:
                value:
                  identifier_type: email
                  value: "thandi@example.com"
      responses:
        "200":
          description: The updated contact. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          description: Invalid identifier type, or contact graph not enabled for the tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "409":
          description: Identifier already belongs to another contact.
          content:
            application/json:
              schema:
                type: object
                properties:
                  detail:
                    type: object
                    properties:
                      message: { type: string }
                      conflict_contact_id: { type: string }
        "429":
          $ref: "#/components/responses/RateLimited"

  /templates/{tenant_id}/stats:
    get:
      operationId: getTemplateStats
      tags: [Templates]
      summary: Get template store statistics
      x-apex-availability: available
      description: Counts of templates by status and category. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      responses:
        "200":
          description: Statistics, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        additionalProperties: true
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /templates/{tenant_id}:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      x-apex-availability: available
      description: List the tenant's message templates with optional filters. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - name: category
          in: query
          schema:
            $ref: "#/components/schemas/TemplateCategory"
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/TemplateStatus"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Templates, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          templates:
                            type: array
                            items:
                              $ref: "#/components/schemas/MessageTemplate"
                          total: { type: integer }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createTemplate
      tags: [Templates]
      summary: Create a template
      x-apex-availability: available
      description: >-
        Create a message template: one logical template with a rendering per channel
        (body, header, footer, buttons, email subject) and typed variable placeholders.
        A WhatsApp rendering must pass the provider approval flow (submit via the
        approvals operation) before it can be used for business-initiated sends.
        Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTemplateRequest"
            examples:
              appointmentReminder:
                value:
                  name: appointment_reminder
                  category: utility
                  locale: en
                  variables:
                    - name: name
                      type: text
                      required: true
                    - name: date
                      type: date
                      required: true
                  channels:
                    whatsapp:
                      body: "Hi {{name}}, this is a reminder of your appointment on {{date}}."
                      footer: "Reply STOP to opt out."
                    email:
                      subject: "Appointment reminder for {{date}}"
                      body: "Hi {{name}},\n\nThis is a reminder of your appointment on {{date}}."
      responses:
        "200":
          description: The created template, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/MessageTemplate"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /templates/{tenant_id}/{template_id}:
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Get a template
      x-apex-availability: available
      description: Fetch one template with all its channel renderings. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/TemplateIdPath"
      responses:
        "200":
          description: The template, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/MessageTemplate"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Template not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"
    put:
      operationId: updateTemplate
      tags: [Templates]
      summary: Update a template
      x-apex-availability: available
      description: >-
        Update any subset of a template's fields. Fields omitted from the body are left
        unchanged. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/TemplateIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateTemplateRequest"
            examples:
              retireTemplate:
                value:
                  status: paused
      responses:
        "200":
          description: The updated template, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/MessageTemplate"
        "400":
          description: No fields to update, or templates not enabled for the tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Template not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: deleteTemplate
      tags: [Templates]
      summary: Delete a template
      x-apex-availability: available
      description: >-
        Delete a template. If the template has a WhatsApp rendering, the registered copy
        is also removed from the messaging provider. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/TemplateIdPath"
      responses:
        "200":
          description: Deletion confirmation, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          deleted: { type: boolean }
                          template_id: { type: string }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Template not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /templates/{tenant_id}/{template_id}/approvals:
    get:
      operationId: listTemplateApprovals
      tags: [Templates]
      summary: List approval statuses for a template
      x-apex-availability: available
      description: >-
        Per-channel approval records for a template — status, provider template id, and
        any rejection reason. Approval statuses also update automatically as the provider
        reaches its verdict. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/TemplateIdPath"
      responses:
        "200":
          description: Approval records, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          approvals:
                            type: array
                            items:
                              $ref: "#/components/schemas/TemplateApproval"
                          total: { type: integer }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Template not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: submitTemplateApproval
      tags: [Templates]
      summary: Submit a template for channel approval
      x-apex-availability: available
      description: |
        Submit a template rendering for the channel's approval process. For WhatsApp this
        forwards the rendering to the messaging provider for review; the provider may
        approve immediately, reject immediately, or leave the approval `pending` until its
        verdict arrives. A provider-side rejection at submission time surfaces as `502`
        with the provider's reason. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/TemplateIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel]
              properties:
                channel:
                  type: string
                  description: The channel rendering to submit, e.g. `whatsapp`.
            examples:
              submitWhatsApp:
                value:
                  channel: whatsapp
      responses:
        "200":
          description: The created approval record, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TemplateApproval"
        "400":
          description: The template has no rendering for the requested channel, or templates not enabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Template not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: The messaging provider rejected the submission; the detail carries the provider's reason.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"

  /forms/{tenant_id}:
    get:
      operationId: listForms
      tags: [Forms]
      summary: List form definitions
      x-apex-availability: available
      description: List the tenant's conversational form definitions. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - name: status
          in: query
          schema:
            type: string
            enum: [active, draft, archived]
          description: Filter by definition status.
      responses:
        "200":
          description: Form definitions, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          forms:
                            type: array
                            items:
                              $ref: "#/components/schemas/FormDefinition"
                          total: { type: integer }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createForm
      tags: [Forms]
      summary: Create a form definition
      x-apex-availability: available
      description: >-
        Define a conversational form: typed fields with validation and options,
        conditional show/hide/require rules, and automations that fire on submission
        (webhook, notification, approval chain, knowledge-base ingestion, workflow).
        The form is decomposed into a chat conversation, one prompt at a time, on
        supported channels. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateFormRequest"
            examples:
              siteVisitRequest:
                value:
                  name: site_visit_request
                  locale: en
                  status: draft
                  fields:
                    - id: full_name
                      type: text
                      label: "What is your full name?"
                      required: true
                    - id: visit_date
                      type: date
                      label: "Which date would you like the visit?"
                      required: true
                    - id: site_type
                      type: select
                      label: "What kind of site is it?"
                      options:
                        - value: residential
                          label: "Residential"
                        - value: commercial
                          label: "Commercial"
                  conditionals:
                    - condition_field: site_type
                      operator: eq
                      condition_value: commercial
                      action: set_required
                      target_field: full_name
                      target_value: true
                  automations:
                    - type: webhook
                      enabled: true
                      config:
                        url: "https://api.your-app.example/hooks/site-visit"
                        method: POST
      responses:
        "200":
          description: The created form definition, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/FormDefinition"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /forms/{tenant_id}/{form_id}:
    get:
      operationId: getForm
      tags: [Forms]
      summary: Get a form definition
      x-apex-availability: available
      description: Fetch one form definition by id. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/FormId"
      responses:
        "200":
          description: The form definition, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/FormDefinition"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Form not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"
    put:
      operationId: updateForm
      tags: [Forms]
      summary: Update a form definition
      x-apex-availability: available
      description: >-
        Update any subset of a form definition's fields; omitted fields are left
        unchanged. Each update increments the definition's version. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/FormId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateFormRequest"
            examples:
              activate:
                value:
                  status: active
      responses:
        "200":
          description: The updated form definition, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/FormDefinition"
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Form not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: archiveForm
      tags: [Forms]
      summary: Archive a form definition
      x-apex-availability: available
      description: >-
        Archives the form (sets its status to `archived`) rather than destroying it;
        existing submissions remain queryable. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/FormId"
      responses:
        "200":
          description: Archive confirmation, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          archived: { type: boolean }
                          form_id: { type: string }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Form not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /forms/{tenant_id}/{form_id}/submissions:
    get:
      operationId: listFormSubmissions
      tags: [Forms]
      summary: List submissions for a form
      x-apex-availability: available
      description: >-
        Completed submissions with the captured field values and the outcome of each
        configured automation. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/FormId"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: Submissions, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          submissions:
                            type: array
                            items:
                              $ref: "#/components/schemas/FormSubmission"
                          total: { type: integer }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Form not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /forms/{tenant_id}/{form_id}/sessions:
    get:
      operationId: listFormSessions
      tags: [Forms]
      summary: List conversational sessions for a form
      x-apex-availability: available
      description: >-
        In-flight conversational sessions — which contact is filling the form, current
        field position, and responses so far. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantId"
        - $ref: "#/components/parameters/FormId"
      responses:
        "200":
          description: Sessions, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          sessions:
                            type: array
                            items:
                              $ref: "#/components/schemas/FormSession"
                          total: { type: integer }
        "400":
          $ref: "#/components/responses/FeatureNotEnabled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/TenantMismatch"
        "404":
          description: Form not found, or unknown tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HttpError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /widgets/delivery-metrics:
    get:
      operationId: getDeliveryMetrics
      tags: [Widgets]
      summary: Get delivery metrics
      x-apex-availability: available
      description: >-
        Send/receive totals, delivered and failed counts, delivery and failure rates,
        and a per-channel breakdown. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantIdQuery"
      responses:
        "200":
          description: Delivery metrics, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          total_sent: { type: integer }
                          total_received: { type: integer }
                          total_messages: { type: integer }
                          delivered: { type: integer }
                          failed: { type: integer }
                          delivery_rate:
                            type: number
                            description: Percentage of all messages delivered.
                          failure_rate:
                            type: number
                            description: Percentage of outbound messages that failed.
                          by_channel:
                            type: object
                            additionalProperties: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /widgets/channel-health:
    get:
      operationId: getChannelHealth
      tags: [Widgets]
      summary: Get per-channel health
      x-apex-availability: available
      description: >-
        Health per configured channel, judged from recent message outcomes: `healthy`,
        `degraded` (more than half of recent messages failed), or `idle` (no recent
        traffic). Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantIdQuery"
      responses:
        "200":
          description: Channel health, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          channels:
                            type: array
                            items:
                              type: object
                              properties:
                                channel: { type: string }
                                configured: { type: boolean }
                                status:
                                  type: string
                                  enum: [healthy, degraded, idle]
                                last_message_at:
                                  type: [string, "null"]
                                  format: date-time
                                recent_message_count: { type: integer }
                                recent_failure_count: { type: integer }
                          total: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /widgets/recent-activity:
    get:
      operationId: getRecentActivity
      tags: [Widgets]
      summary: Get recent message activity
      x-apex-availability: available
      description: The latest messages across all channels, trimmed for display. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantIdQuery"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        "200":
          description: Recent messages, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          messages:
                            type: array
                            items:
                              type: object
                              properties:
                                id: { type: string }
                                direction: { type: string, enum: [inbound, outbound] }
                                channel: { type: string }
                                sender: { type: string }
                                sender_name: { type: [string, "null"] }
                                recipient: { type: string }
                                text:
                                  type: string
                                  description: Message text, truncated to 120 characters.
                                status: { type: [string, "null"] }
                                timestamp: { type: [string, "null"], format: date-time }
                          total: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /search:
    get:
      operationId: searchTenant
      tags: [Search]
      summary: Search contacts, templates, and messages
      x-apex-availability: available
      description: >-
        Federated substring search across the tenant's contacts (name and identifiers),
        templates (name), and messages (text), grouped by entity type. Envelope response.
      parameters:
        - $ref: "#/components/parameters/TenantIdQuery"
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 200 }
          description: Search text.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: Maximum results per entity type.
      responses:
        "200":
          description: Grouped search results, wrapped in the standard envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          results:
                            type: object
                            properties:
                              contacts:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    id: { type: string }
                                    display_name: { type: [string, "null"] }
                                    identifiers: { type: integer }
                                    type: { type: string, const: contact }
                              templates:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    id: { type: string }
                                    name: { type: string }
                                    category: { type: string }
                                    status: { type: string }
                                    type: { type: string, const: template }
                              messages:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    id: { type: string }
                                    text: { type: string }
                                    channel: { type: string }
                                    sender: { type: string }
                                    timestamp: { type: [string, "null"], format: date-time }
                                    type: { type: string, const: message }
                          total: { type: integer }
                          query: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/TenantNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /health:
    get:
      operationId: getHealth
      tags: [Service]
      summary: Service liveness
      x-apex-availability: available
      security: []
      description: >-
        Liveness probe. No authentication required. Returns raw JSON (no envelope) with
        overall status and dependency health.
      responses:
        "200":
          description: Service status. Raw JSON, no envelope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok, degraded]
                  dependencies:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        status: { type: string }

webhooks:
  echo.inbound_message:
    post:
      operationId: onInboundMessage
      summary: Inbound message forwarded to your endpoint
      description: |
        Delivered to your configured `inbound_url` whenever a contact messages your tenant
        on any channel. Webhook forwarding is configured per tenant by the platform
        operator (`enabled`, `inbound_url`, `status_url`, secret, timeout, retry count) —
        typically for API-only ("headless") integrations.

        **Signature.** The request carries `X-Echo-Signature`: the lowercase hex
        HMAC-SHA256 of the exact request body, keyed with your tenant's configured webhook
        secret. Recompute and compare before trusting the payload.

        **Retries.** Non-2xx responses and timeouts are retried with exponential backoff
        up to the configured retry count.

        **Media.** For image and document content, the payload includes a `media_url`
        pointing at Echo's captured copy (`GET /messages/{tenant_id}/media/{channel}/{media_id}`).
        Provider URLs expire within minutes, so always fetch through Echo, persist on your
        side, then burn the copy with the DELETE operation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InboundMessageEvent"
            examples:
              textMessage:
                value:
                  event_type: echo.inbound_message
                  tenant_id: your-tenant
                  timestamp: "2026-08-06T09:41:22.501000+00:00"
                  channel: whatsapp
                  from: "+27821234567"
                  from_name: "Thandi Nkosi"
                  provider_message_id: "wamid.HBgLMjc4M..."
                  content:
                    type: text
                    text: "Hi, I would like to reschedule my site visit."
                  reply_context: {}
              imageMessage:
                value:
                  event_type: echo.inbound_message
                  tenant_id: your-tenant
                  timestamp: "2026-08-06T09:44:03.118000+00:00"
                  channel: whatsapp
                  from: "+27821234567"
                  from_name: "Thandi Nkosi"
                  provider_message_id: "wamid.HBgLMjc4N..."
                  content:
                    type: image
                    url: "1234567890"
                    caption: "Photo of the meter box"
                    media_url: "https://echo.dev.apex.reisiger.org/messages/your-tenant/media/whatsapp/1234567890"
                  reply_context: {}
      responses:
        "200":
          description: Return any 2xx status to acknowledge receipt; anything else is retried.

  echo.status_update:
    post:
      operationId: onStatusUpdate
      summary: Delivery status update forwarded to your endpoint
      description: |
        Delivered to your configured `status_url` as a sent message's delivery state
        advances (for example sent, delivered, read, failed). This is the push half of the
        asynchronous delivery model — the alternative to polling
        `GET /messages/{tenant_id}/{message_id}`. A message that failed permanently
        carries a failure status; its stored record shows the error prefixed
        `DEAD_LETTER:`.

        Signed and retried exactly like `echo.inbound_message`: `X-Echo-Signature` is the
        hex HMAC-SHA256 of the body using your tenant's webhook secret, with exponential
        backoff on failure.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StatusUpdateEvent"
            examples:
              delivered:
                value:
                  event_type: echo.status_update
                  tenant_id: your-tenant
                  timestamp: "2026-08-06T09:42:10.884000+00:00"
                  provider_message_id: "wamid.HBgLMjc4M..."
                  status: delivered
      responses:
        "200":
          description: Return any 2xx status to acknowledge receipt; anything else is retried.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Machine-to-machine access token issued by the platform identity provider, or a platform-issued tenant API key, sent as
        `Authorization: Bearer <credential>`.

  parameters:
    TenantId:
      name: tenant_id
      in: path
      required: true
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9_-]{0,63}$"
      description: Your tenant identifier. Must match the tenant your credential is scoped to.
      example: your-tenant
    TenantIdQuery:
      name: tenant_id
      in: query
      required: true
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9_-]{0,63}$"
      description: Your tenant identifier. Must match the tenant your credential is scoped to.
      example: your-tenant
    ContactId:
      name: contact_id
      in: path
      required: true
      schema: { type: string }
      description: Contact identifier from the contact identity graph.
    LinkId:
      name: link_id
      in: path
      required: true
      schema: { type: string }
      description: Pending merge-link identifier.
    TemplateIdPath:
      name: template_id
      in: path
      required: true
      schema: { type: string }
      description: Template identifier.
    FormId:
      name: form_id
      in: path
      required: true
      schema: { type: string }
      description: Form definition identifier.
    MediaChannel:
      name: channel
      in: path
      required: true
      schema: { type: string }
      description: Channel the media arrived on, e.g. `whatsapp`.
    MediaId:
      name: media_id
      in: path
      required: true
      schema: { type: string }
      description: Provider media identifier, as delivered in the inbound message or webhook payload.

  responses:
    Unauthorized:
      description: Missing or invalid credential.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/HttpError"
    TenantMismatch:
      description: The credential's tenant does not match the requested tenant.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/HttpError"
    TenantNotFound:
      description: Unknown tenant.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/HttpError"
    FeatureNotEnabled:
      description: This capability is not enabled in the tenant's configuration.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/HttpError"
    ValidationError:
      description: Request body failed validation. Envelope-shaped error with code `VALIDATION_ERROR`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    RateLimited:
      description: >-
        Rate limit exceeded (120 requests per minute per credential). Envelope-shaped
        error with code `RATE_LIMITED`.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

  schemas:
    Channel:
      type: string
      enum: [whatsapp, messenger, instagram, telegram, sms, email, forms]
      description: A delivery channel supported by Echo.

    Envelope:
      type: object
      description: >-
        Standard response envelope used by the messages, templates, forms, widget, and
        search routes. `data` carries the operation-specific payload on success; `error`
        is populated on failure.
      properties:
        ok:
          type: boolean
          description: True on success.
        data:
          description: Operation-specific payload; null on error.
        error:
          oneOf:
            - $ref: "#/components/schemas/ErrorDetail"
            - type: "null"
        meta:
          $ref: "#/components/schemas/ResponseMeta"

    ErrorEnvelope:
      type: object
      description: Envelope-shaped error body.
      properties:
        ok:
          type: boolean
          const: false
        data:
          type: "null"
        error:
          $ref: "#/components/schemas/ErrorDetail"
        meta:
          $ref: "#/components/schemas/ResponseMeta"

    ErrorDetail:
      type: object
      properties:
        code:
          type: string
          description: >-
            Stable error code, e.g. `VALIDATION_ERROR`, `RATE_LIMITED`, `TENANT_NOT_FOUND`,
            `CONSENT_DENIED`, `ROUTING_FAILED`, `CHANNEL_NOT_CONFIGURED`, `INTERNAL_ERROR`.
        message:
          type: string
        detail:
          description: Optional structured context for the error.

    ResponseMeta:
      type: object
      properties:
        request_id:
          type: string
          description: Correlates the response with server-side logs.
        timestamp:
          type: string
          format: date-time
        version:
          type: string

    HttpError:
      type: object
      description: Plain error body used by non-envelope error responses.
      properties:
        detail:
          description: Human-readable reason, or a structured object for conflict errors.
          oneOf:
            - type: string
            - type: object
              additionalProperties: true

    SendRequest:
      type: object
      required: [to, content, tenant_id]
      properties:
        id:
          type: [string, "null"]
          description: Client-supplied message identifier; generated when omitted. Reuse it to correlate your own records.
        channel:
          oneOf:
            - $ref: "#/components/schemas/Channel"
            - type: "null"
          description: Target channel. Omit to let the routing engine select the best configured channel for the recipient.
        to:
          type: string
          description: >-
            Recipient address — a phone number in international format, an email address,
            or a platform identifier (for example a Telegram chat id). Validated per channel.
        content:
          $ref: "#/components/schemas/MessageContent"
        tenant_id:
          type: string
          pattern: "^[a-z0-9][a-z0-9_-]{0,63}$"
          description: Your tenant identifier. Must match the tenant your credential is scoped to.
        metadata:
          type: [object, "null"]
          maxProperties: 50
          additionalProperties: true
          description: >-
            Free-form metadata, at most 50 keys. For email sends, the subject line is read
            from `metadata.subject` (default "Echo Notification" when absent).
        category:
          type: string
          default: utility
          description: >-
            Message category used for provider tagging and quota classes, e.g. `utility`,
            `marketing`.
        template_name:
          type: [string, "null"]
          description: >-
            Name of an approved template to render for this send. When set, the template
            rendering replaces `content`.
        template_variables:
          type: [object, "null"]
          additionalProperties: { type: string }
          description: Variable substitutions for the referenced template.
        locale:
          type: [string, "null"]
          description: Locale for template rendering, e.g. `en`.

    SendResponse:
      type: object
      description: Acknowledgement that the message was queued. Delivery is asynchronous.
      properties:
        task_id:
          type: string
          description: Internal queue task identifier.
        message_id:
          type: string
          description: Use this to poll `GET /messages/{tenant_id}/{message_id}`.
        status:
          type: string
          const: queued
        routed_channel:
          type: [string, "null"]
          description: The channel the routing engine selected.

    MessageContent:
      description: >-
        Message content — a discriminated union on `type`. Text, template, image,
        document, and interactive variants are supported.
      oneOf:
        - $ref: "#/components/schemas/TextContent"
        - $ref: "#/components/schemas/TemplateContent"
        - $ref: "#/components/schemas/ImageContent"
        - $ref: "#/components/schemas/DocumentContent"
        - $ref: "#/components/schemas/InteractiveContent"
      discriminator:
        propertyName: type
        mapping:
          text: "#/components/schemas/TextContent"
          template: "#/components/schemas/TemplateContent"
          image: "#/components/schemas/ImageContent"
          document: "#/components/schemas/DocumentContent"
          interactive: "#/components/schemas/InteractiveContent"

    TextContent:
      type: object
      required: [type, text]
      properties:
        type:
          type: string
          const: text
        text:
          type: string

    TemplateContent:
      type: object
      required: [type, template_name]
      description: >-
        Direct reference to a provider-approved template. Accepts snake_case field names
        as documented; camelCase aliases (`templateName`, `languageCode`) are also accepted.
      properties:
        type:
          type: string
          const: template
        template_name:
          type: string
        language_code:
          type: string
          default: en
        components:
          type: [array, "null"]
          items:
            type: object
            additionalProperties: true
          description: Provider-format template components (header, body, button parameters).

    ImageContent:
      type: object
      required: [type, url]
      properties:
        type:
          type: string
          const: image
        url:
          type: string
          description: Publicly retrievable image URL (outbound) or provider media id (inbound records).
        caption:
          type: [string, "null"]

    DocumentContent:
      type: object
      required: [type, url]
      properties:
        type:
          type: string
          const: document
        url:
          type: string
          description: Publicly retrievable document URL (outbound) or provider media id (inbound records).
        filename:
          type: [string, "null"]
        caption:
          type: [string, "null"]

    InteractiveContent:
      type: object
      required: [type, interactive]
      description: >-
        Interactive message — buttons, lists, call-to-action links. Outbound, the
        `interactive` object is passed to the channel provider as-is; inbound, it captures
        the user's button or list reply.
      properties:
        type:
          type: string
          const: interactive
        interactive:
          type: object
          additionalProperties: true

    MessageRecord:
      type: object
      description: >-
        One row in the unified message store — identical structure for every channel and
        direction.
      properties:
        id:
          type: string
          description: Store row identifier.
        message_id:
          type: string
          description: The identifier returned by `POST /send` (or generated for inbound messages).
        provider_message_id:
          type: [string, "null"]
          description: The channel provider's message identifier.
        direction:
          type: string
          enum: [inbound, outbound]
        channel:
          $ref: "#/components/schemas/Channel"
        tenant_id:
          type: string
        contact_id:
          type: [string, "null"]
          description: Resolved contact in the identity graph, when known.
        sender:
          type: string
        sender_name:
          type: [string, "null"]
        recipient:
          type: string
        content_type:
          type: string
          description: Content variant, e.g. `text`, `image`, `document`, `interactive`.
        text:
          type: string
        media_url:
          type: string
          description: Path to captured media for media messages; empty otherwise.
        status:
          type: [string, "null"]
          description: >-
            Delivery state. Progresses from `queued` through provider-reported states such
            as `sent`, `delivered`, `read`; permanent failure is `FAILED`. A message that
            exhausted all retries and channel fallbacks is `FAILED` with `error` prefixed
            `DEAD_LETTER:`.
          x-apex-note: "Schema partially documented — verify the full status vocabulary against the service."
        error:
          type: [string, "null"]
          description: Failure detail; `DEAD_LETTER:`-prefixed when all retries were exhausted.
        timestamp:
          type: [string, "null"]
          format: date-time

    ConversationSummary:
      type: object
      description: One inbox row — the latest state of a conversation with one contact.
      properties:
        contact_id:
          type: [string, "null"]
        contact_key:
          type: string
          description: Stable grouping key; falls back to sender address for unresolved contacts.
        display_name:
          type: [string, "null"]
        last_message_text:
          type: string
          description: Latest message text, truncated to 120 characters.
        last_message_at:
          type: [string, "null"]
          format: date-time
        last_direction:
          type: string
          enum: [inbound, outbound]
        channel:
          $ref: "#/components/schemas/Channel"
        unread_count:
          type: integer
        total_messages:
          type: integer

    IdentifierType:
      type: string
      enum: [phone, email, telegram, messenger_psid, instagram_igsid, form_id]
      description: The kind of channel identifier attached to a contact.

    Contact:
      type: object
      description: A contact in the identity graph — one person across all channels.
      properties:
        id: { type: string }
        tenant_id: { type: string }
        display_name: { type: [string, "null"] }
        identifiers:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              type:
                $ref: "#/components/schemas/IdentifierType"
              value:
                type: string
                description: Normalised identifier value.
              raw_value:
                type: [string, "null"]
                description: The identifier as originally observed.
              channel: { type: [string, "null"] }
              verified_at:
                type: [string, "null"]
                format: date-time
        metadata:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PendingLink:
      type: object
      description: A suggested merge between two contacts that appear to be the same person.
      properties:
        id: { type: string }
        source_contact_id: { type: string }
        target_contact_id: { type: string }
        match_type:
          type: string
          description: What triggered the suggestion, e.g. a shared phone or email.
        confidence:
          type: number
          description: Match confidence between 0 and 1.
        status:
          type: string
          enum: [pending, approved, rejected]
        created_at: { type: string, format: date-time }

    MergeLogEntry:
      type: object
      description: Audit record of a contact merge or split.
      properties:
        id: { type: string }
        operation_type:
          type: string
          enum: [merge, split]
        source_contact_ids:
          type: array
          items: { type: string }
        result_contact_ids:
          type: array
          items: { type: string }
        trigger:
          type: string
          description: >-
            What initiated the operation, e.g. `auto_phone`, `auto_email`, `auto_multi`,
            `explicit_link`, `admin`, `api`, `self_heal`.
        confidence: { type: number }
        actor: { type: string }
        created_at: { type: string, format: date-time }
        reversible_until:
          type: [string, "null"]
          format: date-time

    ReplyQuotaSession:
      type: object
      description: >-
        One service-window session for a contact on a channel, tracking how many
        business replies remain before `POST /send` returns `409`.
      properties:
        id: { type: string }
        channel: { type: string }
        status: { type: string }
        opened_at: { type: string, format: date-time }
        last_inbound_at: { type: string, format: date-time }
        last_inbound_msg_id: { type: [string, "null"] }
        expires_at: { type: string, format: date-time }
        outbound_count: { type: integer }
        max_allowed: { type: integer }
        remaining: { type: integer }
        window_seconds: { type: integer }
        closed_at: { type: [string, "null"], format: date-time }
        closed_reason: { type: [string, "null"] }

    TemplateCategory:
      type: string
      enum: [marketing, utility, authentication, service]
      description: Template category, aligned with provider quota classes.

    TemplateStatus:
      type: string
      enum: [draft, pending, approved, rejected, paused, disabled]

    TemplateVariable:
      type: object
      required: [name]
      properties:
        name: { type: string }
        type:
          type: string
          enum: [text, number, date, email, phone, url, select]
          default: text
        required:
          type: boolean
          default: false
        default:
          type: [string, "null"]
        validation:
          type: [object, "null"]
          additionalProperties: true
          description: Optional constraints such as pattern, minLength, maxLength.

    ChannelRendering:
      type: object
      required: [body]
      description: How the template renders on one channel.
      properties:
        body:
          type: string
          description: Body text with `{{variable}}` placeholders.
        header: { type: [string, "null"] }
        footer: { type: [string, "null"] }
        subject:
          type: [string, "null"]
          description: Email only.
        buttons:
          type: [array, "null"]
          items:
            type: object
            additionalProperties: { type: string }
          description: Button definitions for chat channels.
        format:
          type: string
          default: text
          description: Rendering format, e.g. `text`, `markdown`, `mjml`, `hsm`.

    MessageTemplate:
      type: object
      description: One logical template with a rendering per channel.
      properties:
        id: { type: string }
        tenant_id: { type: string }
        name: { type: string }
        category:
          $ref: "#/components/schemas/TemplateCategory"
        locale: { type: string }
        variables:
          type: array
          items:
            $ref: "#/components/schemas/TemplateVariable"
        channels:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/ChannelRendering"
        version: { type: integer }
        status:
          $ref: "#/components/schemas/TemplateStatus"
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateTemplateRequest:
      type: object
      required: [name, category]
      properties:
        name: { type: string }
        category:
          $ref: "#/components/schemas/TemplateCategory"
        locale:
          type: string
          default: en
        variables:
          type: array
          items:
            $ref: "#/components/schemas/TemplateVariable"
        channels:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/ChannelRendering"
          description: Renderings keyed by channel name, e.g. `whatsapp`, `email`, `sms`.

    UpdateTemplateRequest:
      type: object
      description: Any subset of template fields; omitted fields are unchanged.
      properties:
        name: { type: [string, "null"] }
        category:
          oneOf:
            - $ref: "#/components/schemas/TemplateCategory"
            - type: "null"
        locale: { type: [string, "null"] }
        variables:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/TemplateVariable"
        channels:
          type: [object, "null"]
          additionalProperties:
            $ref: "#/components/schemas/ChannelRendering"
        status:
          oneOf:
            - $ref: "#/components/schemas/TemplateStatus"
            - type: "null"

    TemplateApproval:
      type: object
      description: Per-channel approval record for a template.
      properties:
        id: { type: string }
        template_id: { type: string }
        channel: { type: string }
        status:
          $ref: "#/components/schemas/TemplateStatus"
        provider_template_id:
          type: [string, "null"]
          description: The provider's identifier for the registered template.
        rejection_reason: { type: [string, "null"] }
        submitted_at: { type: [string, "null"], format: date-time }
        resolved_at: { type: [string, "null"], format: date-time }

    FormFieldOption:
      type: object
      required: [value, label]
      properties:
        value: { type: string }
        label: { type: string }
        label_locales:
          type: object
          additionalProperties: { type: string }
          description: Localised labels keyed by locale code.

    FormFieldValidation:
      type: object
      description: Validation rules for a field; all properties optional.
      properties:
        min_length: { type: [integer, "null"] }
        max_length: { type: [integer, "null"] }
        pattern: { type: [string, "null"] }
        min_value: { type: [number, "null"] }
        max_value: { type: [number, "null"] }
        max_files: { type: [integer, "null"] }
        max_file_size:
          type: [integer, "null"]
          description: Maximum file size in bytes.

    FormField:
      type: object
      required: [id, type, label]
      properties:
        id: { type: string }
        type:
          type: string
          enum: [text, textarea, number, select, multi_select, date, email, phone, file, location, boolean, url, rating]
        label: { type: string }
        label_locales:
          type: object
          additionalProperties: { type: string }
        required:
          type: boolean
          default: false
        validation:
          oneOf:
            - $ref: "#/components/schemas/FormFieldValidation"
            - type: "null"
        options:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/FormFieldOption"
          description: Choices for `select` and `multi_select` fields.
        placeholder: { type: [string, "null"] }
        help_text: { type: [string, "null"] }

    FormConditional:
      type: object
      required: [condition_field, condition_value, action, target_field]
      description: "Rule: when a field has a value, show, hide, require, or skip another field."
      properties:
        condition_field: { type: string }
        operator:
          type: string
          enum: [eq, neq, in, not_in, gt, lt]
          default: eq
        condition_value:
          description: Value the condition field is compared against.
        action:
          type: string
          enum: [show_field, hide_field, set_required, skip_field]
        target_field: { type: string }
        target_value:
          description: For `set_required`, true or false.

    FormAutomation:
      type: object
      required: [type]
      description: An action fired when the form is submitted.
      properties:
        type:
          type: string
          enum: [webhook, notification, approval_chain, zenith_ingest, forge_workflow]
        enabled:
          type: boolean
          default: true
        config:
          type: object
          additionalProperties: true
          description: >-
            Type-specific settings. Webhook: url, method, retries, timeout_seconds.
            Approval chain: approvers, timeout_days, notification_channels.
            Knowledge-base ingestion: field-to-attribute mapping. Workflow: workflow_id.
            Notification: channels, template_name.
        trigger_conditions:
          type: array
          items:
            type: object
            additionalProperties: true

    FormDefinition:
      type: object
      description: A complete conversational form definition.
      properties:
        id: { type: string }
        tenant_id: { type: string }
        name: { type: string }
        version: { type: integer }
        fields:
          type: array
          items:
            $ref: "#/components/schemas/FormField"
        conditionals:
          type: array
          items:
            $ref: "#/components/schemas/FormConditional"
        automations:
          type: array
          items:
            $ref: "#/components/schemas/FormAutomation"
        locale: { type: string }
        status:
          type: string
          enum: [active, draft, archived]
        field_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateFormRequest:
      type: object
      required: [name]
      properties:
        name: { type: string }
        fields:
          type: array
          items:
            $ref: "#/components/schemas/FormField"
        conditionals:
          type: array
          items:
            $ref: "#/components/schemas/FormConditional"
        automations:
          type: array
          items:
            $ref: "#/components/schemas/FormAutomation"
        locale:
          type: string
          default: en
        status:
          type: string
          default: draft

    UpdateFormRequest:
      type: object
      description: Any subset of form-definition fields; omitted fields are unchanged.
      properties:
        name: { type: [string, "null"] }
        fields:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/FormField"
        conditionals:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/FormConditional"
        automations:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/FormAutomation"
        locale: { type: [string, "null"] }
        status: { type: [string, "null"] }

    FormSession:
      type: object
      description: An in-flight conversational form session.
      properties:
        id: { type: string }
        tenant_id: { type: string }
        form_id: { type: string }
        contact_id: { type: string }
        channel: { type: string }
        current_field_index: { type: integer }
        responses:
          type: object
          additionalProperties: true
          description: Answers captured so far, keyed by field id.
        status:
          type: string
          enum: [active, completed, timed_out, cancelled, awaiting_confirmation]
        locale: { type: string }
        started_at: { type: string, format: date-time }
        last_activity_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }

    FormSubmission:
      type: object
      description: A completed form submission with automation outcomes.
      properties:
        id: { type: string }
        tenant_id: { type: string }
        form_id: { type: string }
        form_version: { type: integer }
        contact_id: { type: string }
        channel: { type: string }
        data:
          type: object
          additionalProperties: true
          description: Submitted values keyed by field id.
        automation_results:
          type: object
          additionalProperties:
            type: string
            enum: [pending, running, success, failed, skipped]
        submitted_at: { type: string, format: date-time }

    InboundMessageEvent:
      type: object
      description: Payload of the `echo.inbound_message` webhook.
      properties:
        event_type:
          type: string
          const: echo.inbound_message
        tenant_id: { type: string }
        timestamp: { type: string, format: date-time }
        channel:
          $ref: "#/components/schemas/Channel"
        from:
          type: string
          description: Sender address — phone number, email, or platform identifier.
        from_name: { type: [string, "null"] }
        provider_message_id: { type: string }
        content:
          type: object
          description: >-
            Normalised content. Always carries `type`; `text` for text messages; `url`,
            `caption`/`filename`, and a pre-built `media_url` for image and document
            messages; `interactive` for button and list replies.
          properties:
            type:
              type: string
              enum: [text, image, document, interactive]
            text: { type: string }
            url:
              type: string
              description: Provider media identifier for media content.
            caption: { type: [string, "null"] }
            filename: { type: [string, "null"] }
            media_url:
              type: string
              description: >-
                Absolute URL of Echo's captured copy —
                `/messages/{tenant_id}/media/{channel}/{media_id}`. Fetch within the
                retention window (24 hours by default), persist, then burn.
            interactive:
              type: object
              additionalProperties: true
        reply_context:
          type: object
          additionalProperties: { type: string }

    StatusUpdateEvent:
      type: object
      description: Payload of the `echo.status_update` webhook.
      properties:
        event_type:
          type: string
          const: echo.status_update
        tenant_id: { type: string }
        timestamp: { type: string, format: date-time }
        provider_message_id:
          type: string
          description: The provider's message identifier, matching `provider_message_id` on the stored message record.
        status:
          type: string
          description: New delivery state, e.g. `sent`, `delivered`, `read`, `failed`.
          x-apex-note: "Schema partially documented — verify the full status vocabulary against the service."
