openapi: 3.1.0
info:
  title: Tickki Chat SDK API
  version: "1.0.0"
  # Scalar + Redoc both honour `x-logo` — sets the logo in the docs
  # sidebar so the rendered reference matches the rest of the brand.
  x-logo:
    url: https://app.tickki.com/assets/logo.svg
    altText: Tickki
    href: https://app.tickki.com/developers
  contact:
    name: Tickki Developer Support
    url: https://app.tickki.com/developers
    email: developers@tickki.com
  description: |
    The Tickki Chat SDK API lets you embed live customer support chat into your
    own web or mobile app. Your users talk to your agents through the chat UI
    you build; your agents reply from the same Tickki inbox they already use.

    ## What this API is for

    You are integrating Tickki's chat infrastructure into a product **you own**.
    Typical use cases:

    - A SaaS dashboard with a "Help" button that opens a chat
    - A mobile app with an in-app support screen
    - An e-commerce checkout that lets shoppers ping support without leaving the page

    If you just want to drop the standard Tickki widget on a marketing site,
    you don't need this API — use the embed snippet from the Widget settings
    page instead. This API is for when you want full control of the chat UI.

    ## High-level flow

    Every integration follows the same five-step shape:

    1. **Get a key.** A Tickki business owner mints a publishable key
       (`pk_live_*`) from `/settings/developer` and gives it to you.
       The key is safe to embed in client code.
    2. **Fetch config.** Your app calls `GET /chat/config` once at launch
       to learn the branding colors, whether file uploads are enabled, and
       whether any agents are online right now.
    3. **Start a session.** When the user opens the chat, your app calls
       `POST /chat/sessions`. The response contains a `session_token` plus
       the realtime channel name your client should subscribe to.
    4. **Send and receive messages.** Visitor messages go via
       `POST /chat/sessions/{id}/messages`. Agent replies arrive over the
       Reverb (Pusher-protocol) websocket on the private channel — your
       app does not poll.
    5. **Show typing + presence.** Optional but recommended:
       `POST /chat/sessions/{id}/typing` while the user is typing, and
       `POST /chat/sessions/{id}/heartbeat` on a 30s timer so the agent
       sees a green "online" indicator next to the conversation.

    See the [Quickstart](#section/Quickstart) section below for a
    copy-pasteable cURL walkthrough that runs the full loop in under a minute.

    ## Flutter SDK

    If you're building a Flutter app, skip the REST plumbing and use the
    official [`tickki_chat_flutter`](https://pub.dev/packages/tickki_chat_flutter)
    package. Install with `flutter pub add tickki_chat_flutter`. Two ways
    to use it from a single import:

    **Drop-in widget — for when you don't want to build a chat UI:**

    ```dart
    import 'package:tickki_chat_flutter/widget.dart';

    // Somewhere at app start:
    final tickki = TickkiChat(publishableKey: 'pk_live_…');

    // From any onTap, button handler, etc.:
    TickkiChatWidget.show(
      context,
      client: tickki,
      visitorId: 'user_8432',
    );
    ```

    The widget pulls colors, welcome message, and feature flags from
    `GET /chat/config` automatically, so the business owner controls
    branding from the Tickki dashboard — you never have to ship an
    update when their colors change. Pass a `TickkiChatStrings(...)`
    to override the English labels for localized apps.

    **Headless mode — for full UI control:**

    ```dart
    import 'package:tickki_chat_flutter/tickki_chat_flutter.dart';

    final tickki = TickkiChat(publishableKey: 'pk_live_…');

    // Optional: branding before you open the chat.
    final config = await tickki.fetchConfig();

    // Start (or resume) a session.
    final session = await tickki.startSession(
      visitorId: 'user_8432',
      name: 'Jane',
      email: 'jane@example.com',
    );

    // Listen for new messages (REST sends + agent replies, all on the same stream).
    session.messages.listen((m) {
      print('${m.direction}: ${m.content}');
    });

    // Send.
    await session.send('Hi, my order hasn\'t arrived.');

    // Backfill on re-open.
    final page = await session.loadHistory();

    // Optional UX signals.
    session.sendTyping();
    session.startHeartbeat(); // 30s timer; sees the visitor as "online" in the agent UI

    // When the chat screen closes.
    await session.dispose();
    ```

    Realtime uses a small hand-rolled Pusher-protocol client over
    `web_socket_channel` — talks directly to your Tickki Reverb host
    (auto-detected from `GET /chat/config`'s realtime block) and
    handles the private-channel auth handshake against
    `POST /chat/broadcasting/auth` for you. You never deal with the
    Pusher protocol directly.

    **Analytics — track screens, taps, and custom events:**

    From SDK 0.2.0, the same package exposes an analytics surface that
    feeds the `POST /chat/events` endpoint below. Events flow into the
    same Visitor Intelligence pipeline the JS widget feeds, so intent
    scoring / risk scoring / AI summaries / the dashboard's Recent
    Activity feed light up for mobile traffic automatically.

    Three integration tiers, from least to most explicit:

    ```dart
    import 'package:tickki_chat_flutter/widget.dart';

    final tickki = TickkiChat(publishableKey: 'pk_live_…')
      ..analytics.setVisitorId('user_8432');  // required before tracking

    // 1. Auto screen tracking — one line on MaterialApp
    MaterialApp(
      navigatorObservers: [
        TickkiAnalyticsNavigatorObserver(analytics: tickki.analytics),
      ],
      // ...
    )

    // 2. Ambient tap capture + opt-in named taps — wrap once at root
    runApp(TickkiAnalyticsScope(
      analytics: tickki.analytics,
      child: const MyApp(),
    ));

    // Inside the tree — richer named tap on a specific button
    TickkiTrackable(
      name: 'add_to_cart_btn',
      label: 'Add to cart',
      child: ElevatedButton(onPressed: ..., child: const Text('Add to cart')),
    )

    // Opt sensitive subtrees out of ambient capture
    TickkiIgnore(child: PasswordField(...))

    // 3. Manual — anywhere in your app
    tickki.analytics.track('add_to_cart', properties: {
      'product_id': 'sku-123',
      'price_cents': 1999,
    });
    tickki.analytics.trackScreen('CheckoutScreen');
    tickki.analytics.trackTap(label: 'Buy now', key: 'buy_btn');
    ```

    Events buffer in memory and flush every 5 seconds (or on a 20-event
    cap). Network failures re-queue the batch so brief outages don't
    drop events. The SDK auto-detects the host app's bundle id via
    `package_info_plus` and sends it as `X-Tickki-Bundle-Id` so the
    publishable key's allow-list works without manual wiring.

    **Version compatibility**:

    | SDK version | What you get |
    |---|---|
    | 0.1.4+ | Realtime via `web_socket_channel` (the earlier `pusher_channels_flutter`-based releases couldn't connect to self-hosted Reverb). |
    | 0.2.0+ | Analytics surface + `TickkiAnalytics*` widgets. |
    | 0.2.1+ | Drop-in widget topbar redesign — business avatar / name / online indicator instead of a plain title bar. |

    ## Authentication

    Two credentials, layered:

    | Credential | Header | Where it comes from | What it grants |
    |---|---|---|---|
    | **Publishable key** | `Authorization: Bearer pk_live_…` | Minted in `/settings/developer` | Identifies your integration. Required on every request. |
    | **Session token** | `X-Tickki-Session: sess_…` | Returned from `POST /chat/sessions` | Identifies one visitor's conversation. Required on every endpoint that operates on a specific session. |

    The publishable key is safe to embed in client apps. We sha256-hash
    it server-side; the plaintext is shown to the owner exactly once at
    creation. If a key is leaked, the owner revokes it from the
    `/settings/developer` page and our middleware rejects further use
    within seconds.

    The session token scopes a request to one conversation. Even if an
    attacker gets your publishable key, they cannot read or write a
    conversation without also having that conversation's session token —
    which is only ever returned to the client that started the session.

    ### Origin / bundle-id allow-list

    When you mint a publishable key you can optionally list:

    - Browser origins like `https://acme.com`
    - Mobile bundle / package ids like `com.acme.iosapp`

    If the list is non-empty, requests are rejected when the `Origin`
    header (for browsers) or `X-Tickki-Bundle-Id` header (for mobile)
    does not match. Empty list = allow any caller (fine for prototyping,
    discouraged in production).

    ## Rate limits

    All endpoints are bucketed **per API key**:

    | Bucket | Limit | Endpoints |
    |---|---|---|
    | `chat-api` | 300 requests / minute | `POST /sessions`, `POST .../identify`, `POST .../messages`, `POST .../attachments` |
    | `chat-api-light` | 1200 requests / minute | `GET /config`, `GET .../messages`, `POST .../typing`, `POST .../heartbeat`, `POST /broadcasting/auth` |

    Exceeding a bucket returns `429 Too Many Requests` with the
    `rate_limited` error code. Wait one minute and retry — there is no
    Retry-After header today, but one will be added before GA.

    ## Realtime

    Tickki uses [Laravel Reverb](https://reverb.laravel.com/) — wire
    compatible with the Pusher protocol — so any Pusher-protocol client
    works (`pusher-js` in browsers, `pusher_channels_flutter` in Flutter,
    `PusherSwift` on iOS, etc.).

    Subscribe to the **private** channel named in
    `realtime.channel` on the session-start response (it looks like
    `private-chat.session.123`). The Pusher protocol will call our
    `auth_endpoint` (`POST /api/v1/chat/broadcasting/auth`) as part of
    the subscription handshake; you must forward the `Authorization`
    and `X-Tickki-Session` headers on that auth call.

    The event you'll receive is `MessageCreated`. Its payload is the
    same shape as `POST /sessions/{id}/messages` returns, plus
    `attachments[]` if the message has files.

    ## Errors

    Every error response is a JSON object with two stable keys:

    ```json
    { "error": "machine_readable_code", "message": "Human-readable description." }
    ```

    Always branch on `error`, not `message` — we may rewrite the
    message text for clarity but the code string is contract.

    | HTTP | `error` code | Meaning |
    |---|---|---|
    | 400 | `bad_request` | A required body field is missing or malformed. |
    | 401 | `missing_api_key` | No publishable key in `Authorization`. |
    | 401 | `wrong_key_type` | A non-publishable key was used (e.g. a `sk_*` secret). |
    | 401 | `invalid_api_key` | Key not found or revoked. |
    | 401 | `missing_session_token` | A session-scoped endpoint was called without `X-Tickki-Session`. |
    | 401 | `invalid_session` | Session not found, token mismatch, or session belongs to a different business. |
    | 403 | `origin_not_allowed` | Request `Origin` / `X-Tickki-Bundle-Id` is not in the key's allow-list. |
    | 403 | `business_suspended` | The Tickki business behind this key is suspended (billing, etc.). |
    | 403 | `plan_gate` | The business plan does not include this feature (file uploads, voice notes, …). |
    | 403 | `channel_not_allowed` | The broadcasting-auth call asked to subscribe to a channel that doesn't belong to this session. |
    | 422 | `identification_required` | The business requires name + email before `POST /sessions` succeeds. |
    | 422 | `contact_missing` | The session has no contact attached (rare; out-of-band deletion). |
    | 429 | `rate_limited` | Per-key rate limit exceeded. |
    | 503 | `realtime_unconfigured` | The Reverb backend is not reachable; retry shortly. |

    ## Quickstart

    The five calls below get you from zero to a working two-way chat in
    under a minute. Replace `YOUR_PK_KEY` with the publishable key from
    `/settings/developer`.

    **1. Confirm the integration is alive and learn the branding:**
    ```bash
    curl https://app.tickki.com/api/v1/chat/config \
      -H "Authorization: Bearer YOUR_PK_KEY"
    ```

    **2. Start a session (the visitor opens chat):**
    ```bash
    curl -X POST https://app.tickki.com/api/v1/chat/sessions \
      -H "Authorization: Bearer YOUR_PK_KEY" \
      -H "Content-Type: application/json" \
      -d '{"name":"Jane Doe","email":"jane@example.com"}'
    ```
    Save the `session_token` and `conversation_id` from the response.

    **3. Send a visitor message:**
    ```bash
    curl -X POST https://app.tickki.com/api/v1/chat/sessions/$CONVERSATION_ID/messages \
      -H "Authorization: Bearer YOUR_PK_KEY" \
      -H "X-Tickki-Session: $SESSION_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"content":"Hi, my order hasn'\''t arrived."}'
    ```
    The agent's inbox lights up immediately. They reply from the Tickki
    web app, and you receive a `MessageCreated` event on the Reverb
    channel returned in step 2.

    **4. Read history (e.g. on app re-open):**
    ```bash
    curl https://app.tickki.com/api/v1/chat/sessions/$CONVERSATION_ID/messages \
      -H "Authorization: Bearer YOUR_PK_KEY" \
      -H "X-Tickki-Session: $SESSION_TOKEN"
    ```

    **5. Subscribe to realtime updates** with any Pusher-protocol client.
    Hand the client `realtime.key`, `realtime.host`, `realtime.port`,
    `realtime.scheme`, and `realtime.channel` from step 2; configure it
    to call `realtime.auth_endpoint` for private-channel auth and to
    forward the `Authorization` and `X-Tickki-Session` headers on that
    auth call.

servers:
  - url: https://app.tickki.com/api/v1
    description: Production
  - url: http://localhost:8000/api/v1
    description: Local development

security:
  - publishableKey: []

tags:
  - name: Config
    description: Branding, feature flags, and live agent presence. Call once at SDK boot.
  - name: Sessions
    description: Lifecycle of a single visitor conversation.
  - name: Messages
    description: Send and read messages within a session.
  - name: Attachments
    description: File and voice-note uploads attached to a message.
  - name: Presence
    description: Lightweight signals (typing, heartbeat) that drive the agent's "is the user still there?" UI.
  - name: Realtime
    description: Private-channel authorization for the Reverb / Pusher websocket subscription.
  - name: Analytics
    description: Batched event ingestion for screen views, taps, and custom events. Feeds the same Visitor Intelligence pipelines (intent / risk scoring, AI summaries) the JS widget tracker feeds, so mobile traffic shows up in the agent dashboard alongside web visitors.

paths:
  /chat/config:
    get:
      tags: [Config]
      operationId: getConfig
      summary: Get branding, feature flags, and agent presence
      description: |
        Returns everything the SDK needs to render the chat UI before the
        user opens it. Call this once when your app launches and cache
        the result for the duration of the app session.

        Fields you'll typically consume:

        - `branding.primary_color`, `branding.accent_color`, `branding.welcome_message`,
          `branding.logo_url` — paint the chat UI to match the business.
        - `features.file_uploads` — whether to render the attach button.
        - `features.voice_notes` — whether to render the mic button.
        - `limits.message_max_length` — local-validate the input field.
        - `presence.agents_online` — show "Online" badge or "We'll get
          back to you" copy depending on whether it's zero.
        - `identification.required_on_start` — show a pre-chat name/email
          form before calling `POST /sessions`.

        Plan-gated features (`voice_notes`, etc.) are derived from the
        Tickki business's plan tier; you don't need to know the plan
        details, just check the flag.
      responses:
        '200':
          description: Config payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ChatConfig'
              examples:
                default:
                  value:
                    data:
                      business: { id: 42, name: "Acme Co", slug: "acme-co" }
                      branding:
                        primary_color: "#4F46E5"
                        accent_color: "#111827"
                        welcome_message: "Hi! How can we help?"
                        logo_url: "https://app.tickki.com/storage/branding/42/logo.png"
                      features: { file_uploads: true, voice_notes: false }
                      limits: { max_upload_kb: 5120, message_max_length: 8000 }
                      presence: { agents_online: 2 }
                      identification: { required_on_start: true }
        '401':
          $ref: '#/components/responses/AuthError'
        '403':
          $ref: '#/components/responses/AuthError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /chat/sessions:
    post:
      tags: [Sessions]
      operationId: startSession
      summary: Start (or resume) a chat session
      description: |
        Creates a new conversation for the visitor — or, if you pass an
        existing `visitor_id` that was used before, reuses the existing
        conversation so message history persists across app launches.

        ### How `visitor_id` works

        `visitor_id` is a string **you** generate and persist on the
        user's device (e.g. in `AsyncStorage`, `UserDefaults`, or a
        cookie). The same `visitor_id` always maps to the same Tickki
        Contact for a given business — even after app uninstalls if you
        sync it to your own user account.

        If the user is signed into your app, the safe choice is to use
        your own user id (`"user_8432"`). If they're anonymous, generate
        a uuid the first time and reuse it.

        ### Identification

        If the business has "require contact on start" enabled in
        Tickki settings, you must pass `name` and `email` on the first
        `POST /sessions` for a given `visitor_id`. The `GET /config`
        response tells you whether that gate is on
        (`identification.required_on_start`).

        ### Response

        Save the `session_token` securely on the client (in-memory is
        fine — it's tied to the conversation). Hand `realtime` straight
        to your Pusher-protocol client to bring up the websocket.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StartSessionRequest'
            examples:
              anonymous:
                summary: Anonymous first-time visitor
                value:
                  visitor_id: "anon_a1b2c3d4"
              identified:
                summary: User who signed into your app
                value:
                  visitor_id: "user_8432"
                  name: "Jane Doe"
                  email: "jane@example.com"
                  phone: "+14155551234"
      responses:
        '201':
          description: Session created (or resumed). The token is returned exactly once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SessionStarted'
              examples:
                default:
                  value:
                    data:
                      session_id: 17
                      session_token: "sess_3pRfQ8z6N0VxL2sm9bWg7yU1eK5oH4cT"
                      conversation_id: 904
                      visitor_id: "user_8432"
                      contact: { name: "Jane Doe", email: "jane@example.com" }
                      realtime:
                        driver: "reverb"
                        key: "wfyyegbc5ntcnlrjsswq"
                        host: "ws.tickki.com"
                        port: 443
                        scheme: "https"
                        channel: "private-chat.session.904"
                        auth_endpoint: "https://app.tickki.com/api/v1/chat/broadcasting/auth"
                        event: "MessageCreated"
        '401':
          $ref: '#/components/responses/AuthError'
        '422':
          description: Validation error. Either the body shape was bad or the business requires identification on start.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                identification_required:
                  value:
                    error: "identification_required"
                    message: "This business requires the visitor to provide a name and email before starting a chat."
        '429':
          $ref: '#/components/responses/RateLimited'

  /chat/sessions/{session}/identify:
    post:
      tags: [Sessions]
      operationId: identifySession
      summary: Set / update visitor name, email, phone
      description: |
        Updates the Contact attached to a session. Common pattern: the
        SDK starts the session anonymously, then upgrades the Contact
        once the user signs into your app or fills out a pre-chat form.

        Only the fields you send are updated — omit `phone` and the
        existing phone is left alone. Sending `""` (empty string) also
        leaves the field alone (so accidental form submissions don't
        clear good data).
      security:
        - publishableKey: []
          sessionToken: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IdentifyRequest'
            examples:
              upgrade:
                value: { name: "Jane Doe", email: "jane@acme.com" }
      responses:
        '200':
          description: Updated contact snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      visitor_id: { type: string }
                      contact:
                        type: object
                        properties:
                          name: { type: string, nullable: true }
                          email: { type: string, nullable: true }
                          phone: { type: string, nullable: true }
        '401': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /chat/sessions/{session}/messages:
    get:
      tags: [Messages]
      operationId: listMessages
      summary: Paginated message history
      description: |
        Returns messages newest-first. Use this when the user re-opens
        the chat to backfill history, or when paginating older messages.

        Pagination is cursor-based: hand `meta.next_cursor` from one
        response back into the next request's `cursor` query parameter.
        A `null` next cursor means you've reached the start of history.

        Direction values you'll see:

        - `inbound` — the visitor (your user) sent it.
        - `outbound` — an agent sent it.

        Message types:

        - `text` — plain content in `content`.
        - `attachment` — a file is on the message; the realtime event
          payload also carries `attachments[]` with signed URLs.

        Note: you only get history for the session's own conversation.
        Other conversations on the same business are not visible to a
        publishable key, no matter the session token presented.
      security:
        - publishableKey: []
          sessionToken: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - name: cursor
          in: query
          schema: { type: string, nullable: true }
          description: Cursor from a previous response's `meta.next_cursor`.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 30 }
          description: Page size. Capped at 100.
      responses:
        '200':
          description: A page of messages, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Message' }
                  meta:
                    type: object
                    properties:
                      next_cursor: { type: string, nullable: true }
              examples:
                default:
                  value:
                    data:
                      - { id: 1102, direction: "outbound", type: "text", content: "Hey Jane, what's up?", created_at: "2026-05-14T14:32:00+00:00" }
                      - { id: 1101, direction: "inbound", type: "text", content: "Hi, my order hasn't arrived.", created_at: "2026-05-14T14:31:42+00:00" }
                    meta: { next_cursor: null }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }

    post:
      tags: [Messages]
      operationId: sendMessage
      summary: Send a visitor message
      description: |
        Posts a text message from the visitor side and broadcasts it on
        the agent inbox channel + the private SDK channel. Other devices
        on the same session will receive the same `MessageCreated`
        event over the websocket — including the device that sent it,
        so you can use the broadcast as the source of truth instead of
        echoing locally.

        The request returns the persisted message synchronously, so
        for an offline-first UI you can also append it to your local
        list as soon as the HTTP call returns 201.
      security:
        - publishableKey: []
          sessionToken: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMessageRequest'
            examples:
              default: { value: { content: "Hi, my order hasn't arrived." } }
      responses:
        '201':
          description: Message persisted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /chat/sessions/{session}/attachments:
    post:
      tags: [Attachments]
      operationId: uploadAttachment
      summary: Upload an image, document, or voice note
      description: |
        Uploads a file as a `multipart/form-data` request. The file is
        wrapped in a new `attachment`-type Message that the agent sees
        as a single bubble with the file inline.

        ### Limits

        - **Max size**: 5 MB hard cap. Some plans lower this further;
          see `limits.max_upload_kb` in `GET /chat/config`.
        - **Allowed types**: images (jpg, jpeg, png, gif, webp), docs
          (pdf, txt, doc, docx), and audio (webm, m4a, mp3, mp4, ogg,
          oga, wav, aac).
        - **Voice notes** (any `audio/*` mime type) require the
          `voice_notes` feature on the business plan. Check
          `features.voice_notes` from `GET /chat/config` before
          recording.

        ### Signed URL

        The response includes a `signed_url` valid for 60 minutes — use
        it to render an inline preview (image) or a play button (audio).
        Don't cache it past the expiry; if the user reopens the chat
        later, call `GET /chat/sessions/{id}/messages` again and use
        the signed URLs from the fresh attachment payloads.
      security:
        - publishableKey: []
          sessionToken: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: The file being uploaded.
      responses:
        '201':
          description: Attachment persisted; signed URL valid for 60 min.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message_id: { type: integer }
                      attachment:
                        type: object
                        properties:
                          id: { type: integer }
                          file_name: { type: string }
                          mime_type: { type: string }
                          file_size: { type: integer }
                          signed_url: { type: string, nullable: true }
        '401': { $ref: '#/components/responses/AuthError' }
        '403':
          description: Plan does not allow file uploads or this mime type (e.g. audio without the voice_notes feature).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /chat/sessions/{session}/typing:
    post:
      tags: [Presence]
      operationId: visitorTyping
      summary: Broadcast a "visitor is typing" indicator
      description: |
        Fire-and-forget. Call this on each keypress, debounced to
        roughly 1 call every 2-3 seconds — the agent inbox shows a
        typing pill for ~4 seconds after the last event.

        No body. Returns `{ "ok": true }` immediately even if the
        websocket broadcast fails (the indicator is observability, not
        load-bearing).
      security:
        - publishableKey: []
          sessionToken: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Indicator broadcast attempted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /chat/sessions/{session}/heartbeat:
    post:
      tags: [Presence]
      operationId: visitorHeartbeat
      summary: Keep the session marked "active" in the agent UI
      description: |
        Call on a 30-second timer while the chat window is open and
        visible. The agent's inbox uses `last_active_at` to show a
        green "online" indicator next to the conversation — without
        regular heartbeats, the agent will see the visitor as
        "stepped away" even though they're still on the page.

        Stop calling heartbeats when the chat window closes (or your
        app backgrounds for mobile).
      security:
        - publishableKey: []
          sessionToken: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Heartbeat recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  server_time: { type: string, format: date-time, description: "Server's current time; useful for client clock-skew correction." }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /chat/events:
    post:
      tags: [Analytics]
      operationId: trackEvents
      summary: Batch-ingest visitor events (screen views, taps, custom)
      description: |
        Records up to 50 visitor events in a single call. Designed to
        be fed by an in-app SDK that buffers events between flushes —
        the Flutter `tickki_chat_flutter` package does this for you
        automatically with `tickki.analytics.track(...)`. The shape
        is general enough that you can hand-roll it from any HTTP
        client.

        Events feed the same `visitor_events` table the JS widget
        tracker writes to, so the existing Visitor Intelligence
        pipelines (behavior summarisation, intent scoring, risk
        scoring, AI site summaries) automatically include mobile SDK
        traffic. The agent dashboard's "Recent activity" feed surfaces
        SDK-driven sessions exactly like web-widget ones.

        ### Auth

        Publishable key only — **no session token required**. This is
        intentional: most analytics fires before the user opens chat
        (they're just browsing your app), so requiring a chat session
        token would gate 95% of legitimate traffic. The `visitor_id`
        body field is what scopes the events to one visitor.

        ### Event types

        - **`screen_view`** — visitor navigated to a new screen. Set
          `title` to the screen name (`'CheckoutScreen'`) and `url`
          to a stable path identifier (`'tickki://CheckoutScreen'`).
          The mobile analog of the JS widget's `page_view`.
        - **`tap`** — visitor tapped a UI element. Set
          `element_label` to the visible button text (`'Add to cart'`),
          `element_type` to a category (`'button'`, `'link'`, `'icon'`),
          and `element_key` to a stable identifier (`'add_to_cart_btn'`).
        - **`event`** — a named custom event, e.g. `'add_to_cart'`,
          `'signup_completed'`. Set `title` to the event name and put
          structured details in `meta`.

        Other types from the broader Visitor Intelligence taxonomy
        (`form_focus`, `rage_click`, etc.) are also accepted — see
        the [Visitor Intelligence event taxonomy](https://app.tickki.com/developers/docs/spec)
        for the full list.

        ### Rate limits

        Per visitor: 30 batches/minute. With the max 50 events per
        batch, that's a hard ceiling of 1,500 events/min/visitor —
        plenty for any normal usage, deliberately prohibitive for a
        runaway loop. Exceeded calls return 429 with
        `error: rate_limited`.

        ### Privacy

        The shared backend ingestor enforces the same privacy
        invariants the JS widget honours: raw selectors / element-
        ids are sha256-hashed (we never store them in clear), labels
        are bounded to 200 chars + stripped of control characters,
        and sensitive form events (password / payment / cvv) are
        dropped at validation time even if mistagged by the client.
      security:
        - publishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TrackEventsRequest'
            examples:
              flutter_sdk_batch:
                summary: A typical SDK batch (one of each event type)
                value:
                  visitor_id: "user_8432"
                  conversation_id: 904
                  events:
                    - type: "screen_view"
                      title: "CheckoutScreen"
                      url: "tickki://CheckoutScreen"
                      occurred_at: "2026-05-19T14:32:00Z"
                    - type: "tap"
                      element_label: "Add to cart"
                      element_type: "button"
                      element_key: "add_to_cart_btn"
                      occurred_at: "2026-05-19T14:32:02Z"
                    - type: "event"
                      title: "add_to_cart"
                      value: 1999
                      meta: { product_id: "sku-123", currency: "USD" }
                      occurred_at: "2026-05-19T14:32:02Z"
      responses:
        '200':
          description: Batch accepted. `stored` counts persisted events; `rejected` counts events that failed type/field validation in the ingestor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  stored: { type: integer, description: "Number of events persisted." }
                  rejected: { type: integer, description: "Number of events dropped by the ingestor's validation (sensitive event, bad type, etc.)." }
                  code:
                    type: string
                    nullable: true
                    description: "`tracking_disabled` when the business has Visitor Intelligence disabled in settings — the call succeeds with stored=0 so the SDK doesn't retry."
              examples:
                default:
                  value: { ok: true, stored: 3, rejected: 0 }
                disabled:
                  value: { ok: true, stored: 0, code: "tracking_disabled" }
        '401': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429':
          description: Per-visitor rate limit exceeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example: { error: "rate_limited", message: "Per-visitor event ingestion rate limit exceeded." }

  /chat/broadcasting/auth:
    post:
      tags: [Realtime]
      operationId: broadcastingAuth
      summary: Sign a private-channel subscription
      description: |
        The Pusher protocol requires a server-signed auth payload before
        it lets a client subscribe to a private channel. This endpoint
        is that signer, scoped to Tickki sessions.

        ### When this gets called

        You normally do **not** call this directly from application
        code. Configure your Pusher-protocol client with this URL as
        its `authEndpoint` (or library-equivalent), and the library
        will call it automatically during subscription.

        ### What we verify

        1. The publishable key is valid and unrevoked
           (middleware — always).
        2. The session token in `X-Tickki-Session` is valid and belongs
           to the same business as the publishable key.
        3. The `channel_name` you're trying to subscribe to matches
           **this session's** conversation — i.e. you can't sign a
           subscription for another visitor's channel.

        ### Headers your client must forward

        Most Pusher SDKs have an `auth.headers` setting where you put:

        ```
        Authorization: Bearer YOUR_PK_KEY
        X-Tickki-Session: YOUR_SESSION_TOKEN
        ```

        If your client doesn't expose header customization, look for
        an `auth.params` option and we'll fall back to reading from
        the request body / query string in a future release. (Today
        you must use headers.)
      security:
        - publishableKey: []
          sessionToken: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [socket_id, channel_name]
              properties:
                socket_id:
                  type: string
                  description: Pusher socket id, supplied by the client library.
                channel_name:
                  type: string
                  description: The private channel you want to subscribe to. Must equal `private-chat.session.{conversation_id}` for the session you authenticated with.
      responses:
        '200':
          description: Signed auth payload for the Pusher subscription handshake.
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth:
                    type: string
                    example: "wfyyegbc5ntcnlrjsswq:c49b05c8184f81c970e4dc59e1fd9de880d703ca1821a784a955a5fba833b18e"
                    description: "Format: `<app_key>:<HMAC-SHA256(secret, '<socket_id>:<channel_name>')>`"
        '400':
          description: socket_id or channel_name missing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/AuthError' }
        '403':
          description: Channel does not belong to this session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                default: { value: { error: "channel_not_allowed", message: "This session is not allowed to subscribe to the requested channel." } }
        '503':
          description: Reverb backend not configured. Retry.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

components:
  parameters:
    SessionId:
      name: session
      in: path
      required: true
      schema: { type: integer }
      description: The `conversation_id` returned by `POST /chat/sessions`. Despite the URL segment being named `session`, the value is the conversation id (these two are 1:1 in v1).

  securitySchemes:
    publishableKey:
      type: http
      scheme: bearer
      bearerFormat: pk_live_*
      description: |
        Publishable key minted from `/settings/developer`. Send as
        `Authorization: Bearer pk_live_…` on every request.
    sessionToken:
      type: apiKey
      in: header
      name: X-Tickki-Session
      description: |
        Per-session token returned in the `data.session_token` field of
        `POST /chat/sessions`. Required on every endpoint that operates
        on a specific session.

  responses:
    AuthError:
      description: Authentication or authorization failed. See the `error` code.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            missing_api_key:
              value: { error: "missing_api_key", message: "Provide a publishable API key in the Authorization header." }
            invalid_api_key:
              value: { error: "invalid_api_key", message: "API key is invalid or has been revoked." }
            origin_not_allowed:
              value: { error: "origin_not_allowed", message: "This origin or bundle id is not in the key allow-list." }
            invalid_session:
              value: { error: "invalid_session", message: "Session not found, token mismatch, or session belongs to a different business." }

    ValidationError:
      description: Body validation failed. Inspect the `errors` map for field-level details.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string }
              errors:
                type: object
                additionalProperties:
                  type: array
                  items: { type: string }
            example:
              message: "The given data was invalid."
              errors:
                content: ["The content field is required."]

    RateLimited:
      description: Per-key rate limit exceeded.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: "rate_limited"
            message: "Too many requests. Slow down and retry shortly."

  schemas:
    ErrorEnvelope:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: Machine-readable error code. Branch on this, never on `message`.
        message:
          type: string
          description: Human-readable description suitable for surfacing to a developer in logs.

    ChatConfig:
      type: object
      properties:
        business:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            slug: { type: string }
        branding:
          type: object
          properties:
            primary_color: { type: string, description: "Hex color, e.g. #4F46E5" }
            accent_color: { type: string }
            welcome_message: { type: string }
            logo_url: { type: string, nullable: true }
        features:
          type: object
          properties:
            file_uploads: { type: boolean }
            voice_notes: { type: boolean }
        limits:
          type: object
          properties:
            max_upload_kb: { type: integer, description: "Per-file upload size cap in KB." }
            message_max_length: { type: integer, description: "Max characters per message content body." }
        presence:
          type: object
          properties:
            agents_online: { type: integer, description: "Count of agents whose presence is currently `online`. Use this to render Online/Offline copy." }
        identification:
          type: object
          properties:
            required_on_start:
              type: boolean
              description: When true, `POST /chat/sessions` will reject requests without `email` (and typically `name`). Surface a pre-chat form when true.

    StartSessionRequest:
      type: object
      properties:
        visitor_id:
          type: string
          maxLength: 128
          description: Stable identifier for this visitor. See "How visitor_id works" in the description.
        name: { type: string, maxLength: 120 }
        email: { type: string, format: email, maxLength: 190 }
        phone: { type: string, maxLength: 40 }

    SessionStarted:
      type: object
      properties:
        session_id:
          type: integer
          description: Internal id of the `chat_sdk_sessions` row. Not the same as `conversation_id`; use `conversation_id` in URLs.
        session_token:
          type: string
          description: Bearer-style token. Send back on subsequent calls as `X-Tickki-Session`. Shown exactly once.
        conversation_id:
          type: integer
          description: The id you put in subsequent path segments (`/sessions/{conversation_id}/messages`, etc.).
        visitor_id:
          type: string
        contact:
          type: object
          properties:
            name: { type: string, nullable: true }
            email: { type: string, nullable: true }
        realtime:
          $ref: '#/components/schemas/RealtimeConfig'

    RealtimeConfig:
      type: object
      properties:
        driver: { type: string, enum: [reverb], description: "Always `reverb` today. Reserved for future drivers." }
        key: { type: string, description: "Pusher / Reverb app key. NOT the same as your publishable API key." }
        host: { type: string, description: "Websocket host. Configure your Pusher client to connect here." }
        port: { type: integer }
        scheme: { type: string, enum: [http, https] }
        channel: { type: string, description: "Subscribe to this exact channel name. Format: `private-chat.session.<conversation_id>`." }
        auth_endpoint: { type: string, description: "Your Pusher client must call this URL during the subscription handshake." }
        event: { type: string, description: "Bind this event name on the channel. Today only `MessageCreated` fires for SDK consumers." }

    IdentifyRequest:
      type: object
      properties:
        name: { type: string, maxLength: 120 }
        email: { type: string, format: email, maxLength: 190 }
        phone: { type: string, maxLength: 40 }

    SendMessageRequest:
      type: object
      required: [content]
      properties:
        content:
          type: string
          maxLength: 8000
          description: Plain text. No HTML rendering on the agent side; long URLs and emoji are fine.

    Message:
      type: object
      properties:
        id: { type: integer }
        direction: { type: string, enum: [inbound, outbound], description: "`inbound` = visitor sent it, `outbound` = agent sent it." }
        type: { type: string, enum: [text, attachment], description: "`attachment`-type messages also include an `attachments[]` array on the realtime broadcast payload (not on this REST shape)." }
        content: { type: string }
        created_at: { type: string, format: date-time }

    TrackEventsRequest:
      type: object
      required: [visitor_id, events]
      properties:
        visitor_id:
          type: string
          maxLength: 128
          description: Same stable id you pass to `POST /chat/sessions`. Events are scoped per visitor.
        conversation_id:
          type: integer
          nullable: true
          description: When a chat is open, pass the conversation id so events attach to that conversation in the agent UI as part of the visitor journey.
        events:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/TrackEvent' }

    TrackEvent:
      type: object
      required: [type]
      properties:
        type:
          type: string
          description: "Event type. Common values: `screen_view`, `tap`, `event`. Anything in the broader Visitor Intelligence taxonomy is also accepted; unknown types are dropped by the ingestor."
        title: { type: string, maxLength: 255, description: "Screen name (screen_view) or event name (event)." }
        url: { type: string, maxLength: 1024, description: "Stable path-style identifier; for mobile screens use `tickki://ScreenName`." }
        element_label: { type: string, maxLength: 200, description: "Human-readable label of the thing interacted with (tap events)." }
        element_type: { type: string, maxLength: 32, description: "Loose category — `button`, `link`, `icon`, `row`, etc." }
        element_key: { type: string, maxLength: 120, description: "Stable identifier for the element across renders." }
        value: { description: "Optional numeric or short-string payload." }
        occurred_at: { type: string, format: date-time, description: "ISO-8601. Defaults to server `now()` if omitted." }
        meta: { type: object, description: "Arbitrary additional fields. Persisted into the event's meta column for analytics queries." }
