openapi: 3.1.0
info:
  title: Replai API
  version: 1.0.0
  description: |
    Replai REST API — WhatsApp automation for LATAM SMBs.

    All endpoints under `/v1/*` require a Bearer API key. Get one from
    the dashboard at Settings → API keys.

    **Idempotency.** Outbound message sends accept `clientMessageId` —
    if the same id is reused, the second request returns the original
    job id rather than enqueuing a duplicate. Always set this in
    production.

    **Pagination.** List endpoints accept `limit` (default 50, max 200)
    and return `nextCursor` (opaque) — pass it as `before` for the
    next page.

    **Errors.** Every error body follows
    `{ "code": "VALIDATION_FAILED", "message": "human readable",
       "requestId": "uuid", "meta": { ... } }`. Surface `requestId` to
    your users — we can look it up.
  contact:
    name: Replai
    url: https://srv977695.hstgr.cloud
  license:
    name: UNLICENSED
servers:
  - url: https://srv977695.hstgr.cloud/api
    description: Production
  - url: http://localhost:3001
    description: Local development
security:
  - bearerAuth: []
tags:
  - name: Sessions
    description: WhatsApp number lifecycle (create, pair, monitor, retire).
  - name: Messages
    description: Send outbound messages (text + media).
  - name: Conversations
    description: Read message history + toggle bot per chat.
  - name: Media
    description: Upload bytes once, reference by id when sending.
  - name: Knowledge
    description: Per-session knowledge base (RAG corpus).
  - name: Webhooks
    description: HTTP delivery endpoints for inbound events.
  - name: Identity
    description: Inspect the calling API key.

paths:
  /v1/me:
    get:
      tags: [Identity]
      summary: Who am I?
      description: Returns the tenant id + scopes attached to the current API key.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenantId: { type: string }
                  apiKey:
                    type: object
                    properties:
                      id: { type: string }
                      scopes:
                        type: array
                        items: { type: string }

  /v1/sessions:
    get:
      tags: [Sessions]
      summary: List sessions
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Session' }
    post:
      tags: [Sessions]
      summary: Create session
      description: |
        Creates a session in `PENDING` state. To pair a number, open the
        SSE stream at `/v1/sessions/:id/events` and wait for a `qr` event.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: 'Soporte Caracas' }
                adapter:
                  type: string
                  enum: [COMPANION, STANDALONE, WEB_WIDGET]
                  default: COMPANION
      responses:
        '200':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }

  /v1/sessions/{id}:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Sessions]
      summary: Get session
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
    patch:
      tags: [Sessions]
      summary: Update session
      description: Editable fields only. Status/msisdn/adapter are derived from the lifecycle.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                botName: { type: string }
                botPersona: { type: string }
                botInstructions: { type: string }
                botGreeting: { type: string }
                botFallback: { type: string }
                botModel: { type: string }
                botGroupMode:
                  type: string
                  enum: [OFF, MENTION_ONLY, ALWAYS]
                humanProfile:
                  type: string
                  enum: [rapid, normal, relaxed, off]
                quietHours:
                  oneOf:
                    - type: 'null'
                    - $ref: '#/components/schemas/QuietHours'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }

  /v1/sessions/{id}/metrics:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Sessions]
      summary: Session metrics (last 24h + cumulative)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionMetrics' }

  /v1/plugins/manifest:
    get:
      tags: [Plugins]
      summary: List loaded plugins
      description: |
        Returns the plugins this API process has loaded at boot,
        with their version, permissions, description, and the route
        prefix each owns. Plugins are configured via the
        `REPLAI_PLUGINS` env var on the host. Adding / removing
        plugins requires an API restart.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [count, plugins]
                properties:
                  count: { type: integer }
                  plugins:
                    type: array
                    items:
                      type: object
                      required: [name, version, description, permissions, spec, mountPath]
                      properties:
                        name: { type: string, example: echo }
                        version: { type: string, example: 0.1.0 }
                        description: { type: [string, 'null'] }
                        permissions:
                          type: array
                          items: { type: string }
                          example: [inbound:read, outbound:send]
                        spec:
                          type: string
                          description: Module spec that was loaded (npm name or file URL).
                          example: '@replai/plugin-echo'
                        mountPath:
                          type: string
                          example: /v1/plugins/echo

  /v1/sessions/{id}/stream:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Sessions]
      summary: Live event stream (WebSocket)
      description: |
        Open a **WebSocket** at `wss://app.replai.tech/api/v1/sessions/{id}/stream`
        (note the `wss://` scheme). The server immediately sends a
        `snapshot` frame (current status + readiness obligations),
        then multiplexes every subsequent event for this session:

          - `kind: "lifecycle"` — wrap of the worker pubsub event
            (`connecting`, `qr`, `connected`, `disconnected`,
            `logged_out`)
          - `kind: "inbox"` — wrap of an InboxEvent
            (`message.received`, `message.sent`, `message.delivered`,
            `message.read`) filtered to this session
          - `kind: "heartbeat"` — every 25s; defeats proxy idle timeouts
          - `kind: "error"` — followed by `socket.close(4xxx)` if the
            stream can't proceed

        Auth: same Bearer API key as the rest of `/v1`. Pass it via
        the query string (`?api_key=...`) since browsers can't set
        Authorization on the WS handshake, or via a fetch-based
        client that supports custom WS headers.
      responses:
        '101':
          description: Switching Protocols — WebSocket established
        '404':
          description: session not found

  /v1/sessions/{id}/readiness:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Sessions]
      summary: Per-session readiness snapshot
      description: |
        Returns an explicit readiness model — each blocking obligation
        (paired, connected, not quarantined, not logged out) plus
        diagnostic non-blocking signals (heartbeat freshness, inbound
        activity). `dispatchable` is true iff every blocking obligation
        is `satisfied`. Use this for "why can't this session send?"
        surfaces in the dashboard.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [sessionId, dispatchable, computedAt, obligations]
                properties:
                  sessionId: { type: string }
                  dispatchable: { type: boolean }
                  computedAt: { type: string, format: date-time }
                  obligations:
                    type: array
                    items:
                      type: object
                      required: [name, label, state, detail, blocking]
                      properties:
                        name: { type: string }
                        label: { type: string }
                        state:
                          type: string
                          enum: [satisfied, pending, failed, non_blocking]
                        detail: { type: [string, 'null'] }
                        blocking: { type: boolean }

  /v1/sessions/{id}/events:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Sessions]
      summary: Subscribe to session lifecycle events (SSE)
      description: |
        Server-sent events stream. Each event is a JSON line preceded by
        `data: `. Events: `snapshot`, `connecting`, `qr`, `connected`,
        `disconnected`, `logged_out`.
      responses:
        '200':
          description: text/event-stream
          content:
            text/event-stream:
              schema:
                type: string
                example: |
                  data: {"type":"qr","sessionId":"…","qrDataUrl":"data:image/png;base64,…"}

  /v1/messages:
    post:
      tags: [Messages]
      summary: Send a message
      description: |
        Asynchronous send. Returns `202 Accepted` with a `jobId` — the
        worker dequeues and ships to WhatsApp. Watch your webhook for
        the `message.sent` event.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/SendTextInput'
                - $ref: '#/components/schemas/SendMediaInput'
      responses:
        '202':
          description: Accepted (queued)
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: boolean, example: true }
                  jobId: { type: string }
                  to: { type: string, description: "Normalised JID we'll deliver to." }

  /v1/conversations:
    get:
      tags: [Conversations]
      summary: List conversations
      parameters:
        - name: sessionId
          in: query
          required: true
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Conversation' }

  /v1/conversations/{id}/messages:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Conversations]
      summary: Read conversation messages
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: before
          in: query
          schema: { type: string, description: 'Cursor — pass `nextCursor` from a previous page.' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Message' }
                  nextCursor:
                    type: string
                    nullable: true

  /v1/conversations/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Conversations]
      summary: Toggle bot for this conversation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [botEnabled]
              properties:
                botEnabled: { type: boolean }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Conversation' }

  /v1/media/upload:
    post:
      tags: [Media]
      summary: Request a signed upload URL
      description: |
        Upload flow:
        1. POST `/v1/media/upload` with mime + size → get `signedUploadUrl` + `id`.
        2. PUT the bytes directly to `signedUploadUrl` with `Content-Type`
           set to the same mime you declared.
        3. Pass the returned `id` as `mediaId` to `/v1/messages`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mimeType, byteSize]
              properties:
                mimeType: { type: string, example: 'image/png' }
                byteSize: { type: integer, minimum: 1, maximum: 52428800 }
                filename: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MediaUpload' }

  /v1/media/{id}/signed-url:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Media]
      summary: Get a signed GET URL for a previously uploaded media
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  expiresAt: { type: string, format: date-time }

  /v1/knowledge:
    get:
      tags: [Knowledge]
      summary: List knowledge sources
      parameters:
        - name: sessionId
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/KnowledgeSource' }
    post:
      tags: [Knowledge]
      summary: Create a knowledge source
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId, kind, title]
              properties:
                sessionId: { type: string }
                kind: { type: string, enum: [TEXT, URL] }
                title: { type: string }
                content: { type: string, description: 'Required when kind=TEXT.' }
                url:
                  type: string
                  format: uri
                  description: 'Required when kind=URL.'
      responses:
        '200':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeSource' }

  /v1/knowledge/{id}/refresh:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Knowledge]
      summary: Re-index a knowledge source
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeSource' }

  /v1/knowledge/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Knowledge]
      summary: Delete a knowledge source
      responses:
        '204':
          description: Deleted

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookEndpoint' }
    post:
      tags: [Webhooks]
      summary: Create a webhook endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url]
              properties:
                name: { type: string }
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  description: |
                    Subset of: `message.received`, `message.sent`,
                    `message.delivered`, `message.read`, `message.failed`,
                    `session.connected`, `session.disconnected`,
                    `session.qr_generated`, `session.logged_out`.
                    Default: all events.
      responses:
        '200':
          description: Created — the `secret` field is returned ONCE here.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookEndpoint'
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: 'Shared secret for HMAC verification — store securely.'

  /v1/webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Webhooks]
      summary: Update / pause / resume a webhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                status:
                  type: string
                  enum: [ACTIVE, PAUSED, DISABLED]
      responses:
        '200':
          description: OK
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      responses:
        '204':
          description: Deleted

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key

  parameters:
    SessionId:
      name: id
      in: path
      required: true
      schema: { type: string }

  schemas:
    Session:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        msisdn: { type: string, nullable: true }
        adapter:
          type: string
          enum: [COMPANION, STANDALONE, WEB_WIDGET]
        status:
          type: string
          enum: [PENDING, AWAITING_QR, CONNECTED, RECONNECTING, LOGGED_OUT, ARCHIVED]
        lastSeenAt: { type: string, format: date-time, nullable: true }
        loggedOutAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    SessionMetrics:
      type: object
      properties:
        sessionId: { type: string }
        status: { type: string }
        msisdn: { type: string, nullable: true }
        uptimeMs: { type: integer }
        conversations: { type: integer }
        totalMessages: { type: integer }
        last24h:
          type: object
          properties:
            inbound: { type: integer }
            outbound: { type: integer }
        lastInboundAt: { type: string, format: date-time, nullable: true }
        lastOutboundAt: { type: string, format: date-time, nullable: true }

    Conversation:
      type: object
      properties:
        id: { type: string }
        sessionId: { type: string }
        remoteJid: { type: string }
        lastAt: { type: string, format: date-time }
        unreadCount: { type: integer }
        botEnabled: { type: boolean }

    Message:
      type: object
      properties:
        id: { type: string }
        conversationId: { type: string }
        direction:
          type: string
          enum: [INBOUND, OUTBOUND]
        body:
          type: object
          description: Persisted body — discriminated by `type`.
        status:
          type: string
          enum: [PENDING, SENT, DELIVERED, READ, FAILED]
        createdAt: { type: string, format: date-time }
        editedAt: { type: string, format: date-time, nullable: true }
        deletedAt: { type: string, format: date-time, nullable: true }

    QuietHours:
      type: object
      properties:
        fromHour: { type: integer, minimum: 0, maximum: 23 }
        toHour: { type: integer, minimum: 0, maximum: 23 }
        timezone: { type: string, default: 'America/Caracas' }
      required: [fromHour, toHour]

    SendTextInput:
      type: object
      required: [sessionId, to, text]
      properties:
        sessionId: { type: string }
        to: { type: string, description: 'E.164 or full JID (auto-normalised).' }
        text: { type: string }
        clientMessageId: { type: string }

    SendMediaInput:
      type: object
      required: [sessionId, to, media]
      properties:
        sessionId: { type: string }
        to: { type: string }
        media:
          type: object
          required: [type]
          properties:
            type:
              type: string
              enum: [image, video, audio, document, sticker]
            mediaId: { type: string, description: 'Preferred — id from /v1/media/upload.' }
            url:
              type: string
              format: uri
              description: "Public URL we'll fetch at send time. Mutually exclusive with mediaId."
            caption: { type: string }
            filename:
              type: string
              description: 'Required for type=document.'
            isVoiceNote:
              type: boolean
              description: 'type=audio only — send as a PTT voice note.'
        clientMessageId: { type: string }

    KnowledgeSource:
      type: object
      properties:
        id: { type: string }
        sessionId: { type: string }
        kind:
          type: string
          enum: [TEXT, URL, FILE]
        title: { type: string }
        status:
          type: string
          enum: [DRAFT, INDEXING, READY, FAILED]
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    MediaUpload:
      type: object
      properties:
        id: { type: string }
        storageKey: { type: string }
        mimeType: { type: string }
        byteSize: { type: integer }
        signedUploadUrl: { type: string, format: uri }
        expiresAt: { type: string, format: date-time }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        status:
          type: string
          enum: [ACTIVE, PAUSED, DISABLED]
        successCount: { type: integer }
        failureCount: { type: integer }
        lastDeliveryAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }

    Error:
      type: object
      properties:
        code: { type: string }
        message: { type: string }
        requestId: { type: string }
        meta:
          type: object
          additionalProperties: true
