openapi: 3.1.0
info:
  title: Tallker API
  version: 1.1.0
  description: |
    Production API contract shared by the Tallker web cabinet and planned Android/Windows clients.
    Native clients use Bearer access tokens, cursor pagination, idempotent message sends, the WebSocket
    realtime stream, device registration and the durable event log for reconnect recovery.
servers:
  - url: https://tallker.com/api/v1
security:
  - bearerAuth: []
paths:
  /public/contacts/upsert:
    post:
      summary: Create or update a contact by external ID
      security: [{ developerApiKey: [contacts:write] }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExternalContact' } } } }
      responses: { '200': { description: Contact upserted }, '400': { $ref: '#/components/responses/Problem' }, '401': { $ref: '#/components/responses/Problem' }, '429': { $ref: '#/components/responses/Problem' } }
  /public/contacts/{external_id}:
    get:
      summary: Read a contact by external ID
      security: [{ developerApiKey: [contacts:read] }]
      parameters: [{ in: path, name: external_id, required: true, schema: { type: string, maxLength: 191 } }]
      responses: { '200': { description: Contact }, '404': { $ref: '#/components/responses/Problem' } }
  /public/orders:
    post:
      summary: Create or update an order
      security: [{ developerApiKey: [orders:write] }]
      parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 191 } }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExternalOrder' } } } }
      responses: { '200': { description: Order updated }, '201': { description: Order created }, '409': { $ref: '#/components/responses/Problem' } }
  /public/orders/{external_id}:
    get:
      summary: Read an order by external ID
      security: [{ developerApiKey: [orders:read] }]
      parameters: [{ in: path, name: external_id, required: true, schema: { type: string, maxLength: 191 } }]
      responses: { '200': { description: Order }, '404': { $ref: '#/components/responses/Problem' } }
  /public/widget/identity:
    post:
      summary: Create a 15-minute opaque widget identity token
      security: [{ developerApiKey: [widget_identity:write] }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WidgetIdentity' } } } }
      responses: { '201': { description: Identity token created } }
  /version:
    get:
      security: []
      summary: API build version
      responses:
        '200':
          description: Version
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string }
  /auth/register:
    post:
      security: []
      summary: Create a user and workspace
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterRequest' }
      responses:
        '201': { $ref: '#/components/responses/Session' }
        '400': { $ref: '#/components/responses/Problem' }
        '409': { $ref: '#/components/responses/Problem' }
  /auth/login:
    post:
      security: []
      summary: Start an authenticated session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
      responses:
        '200': { $ref: '#/components/responses/Session' }
        '401': { $ref: '#/components/responses/Problem' }
  /auth/refresh:
    post:
      security: []
      summary: Rotate a refresh token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                refreshToken: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Session' }
        '401': { $ref: '#/components/responses/Problem' }
  /auth/logout:
    post:
      security: []
      summary: Revoke a refresh token
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refreshToken: { type: string }
      responses:
        '204': { description: Session revoked }
  /auth/me:
    get:
      summary: Current authenticated user
      responses:
        '200': { description: Current user }
        '401': { $ref: '#/components/responses/Problem' }
  /bootstrap:
    get:
      summary: Web cabinet bootstrap snapshot
      responses:
        '200': { description: Current workspace snapshot }
        '401': { $ref: '#/components/responses/Problem' }
  /workspace:
    get:
      summary: Get current workspace
      responses:
        '200':
          description: Workspace
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Workspace' }
    patch:
      summary: Update workspace and profile locale
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 120 }
                timezone: { type: string, maxLength: 80 }
                language: { $ref: '#/components/schemas/Locale' }
      responses:
        '200': { $ref: '#/components/responses/Success' }
  /conversations:
    get:
      summary: List conversations using stable cursor pagination
      parameters:
        - in: query
          name: limit
          required: true
          description: Native pagination opt-in. Use 1-200; 50 is recommended.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: cursor
          description: Opaque nextCursor from the previous response.
          schema: { type: string }
      responses:
        '200':
          description: Conversation page ordered newest first
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConversationPage'
        '401': { $ref: '#/components/responses/Problem' }
  /conversations/{id}/messages:
    get:
      summary: Load one chronological page of messages
      parameters:
        - $ref: '#/components/parameters/ConversationId'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: before
          description: Opaque nextCursor for loading older messages.
          schema: { type: string }
      responses:
        '200':
          description: Message page ordered oldest to newest inside the returned page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessagePage' }
        '404': { $ref: '#/components/responses/Problem' }
  /conversations/{id}:
    patch:
      summary: Change read, typing, assignment or status state
      parameters:
        - $ref: '#/components/parameters/ConversationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                unread: { type: integer, minimum: 0 }
                markRead: { type: boolean }
                typing: { type: boolean }
                take: { type: boolean }
                status: { type: string, enum: [open, closed] }
      responses:
        '200': { $ref: '#/components/responses/Success' }
  /conversations/{id}/take:
    post:
      summary: Assign a conversation to the current user
      parameters:
        - $ref: '#/components/parameters/ConversationId'
      responses:
        '200': { $ref: '#/components/responses/Success' }
  /conversations/{id}/email-reply:
    post:
      summary: Send a conversation reply by email through Unisender Go
      parameters:
        - $ref: '#/components/parameters/ConversationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 4000 }
                idempotenceKey: { type: string, maxLength: 96 }
      responses:
        '202': { description: Email accepted and message saved }
  /messages:
    post:
      summary: Send an idempotent operator message
      description: |
        Native clients should generate one UUID/ULID per user send action and pass it as idempotencyKey
        (or Idempotency-Key header). Retrying the same send returns the existing message id instead of
        creating a duplicate.
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, maxLength: 96 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageRequest' }
      responses:
        '201':
          description: Message created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendMessageResponse' }
        '200':
          description: Duplicate retry resolved to the original message
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendMessageResponse' }
        '404': { $ref: '#/components/responses/Problem' }
  /realtime:
    get:
      summary: Upgrade to the workspace realtime WebSocket stream
      description: |
        Send a normal authenticated GET with `Upgrade: websocket`. Browser clients can authenticate
        through the Tallker cookies; Android/Windows clients should send `Authorization: Bearer <accessToken>`.
        The server sends JSON events such as `message.created`, `conversation.created`,
        `conversation.updated`, `conversation.typing`, `conversation.read` and `feedback.updated`.
        Durable events can be recovered with GET /events after reconnect.
      responses:
        '101': { description: Switching Protocols }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
      x-websocket-events:
        schema: { $ref: '#/components/schemas/RealtimeEvent' }
  /events:
    get:
      summary: Recover durable realtime events after reconnect
      parameters:
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: after
          description: Opaque nextCursor from the previous response.
          schema: { type: string }
      responses:
        '200':
          description: Durable event page in chronological order
          content:
            application/json:
              schema:
                type: object
                required: [items, nextCursor]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/RealtimeEvent' }
                  nextCursor: { type: string }
  /devices:
    get:
      summary: List registered native devices for the current user
      responses:
        '200': { description: Device list }
    post:
      summary: Register or refresh an Android/Windows device
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DeviceRegistration' }
      responses:
        '201':
          description: Registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  registered: { type: boolean }
  /devices/{id}:
    delete:
      summary: Remove a device registration on logout/uninstall
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '204': { description: Device removed }
        '404': { $ref: '#/components/responses/Problem' }
  /contacts:
    get:
      summary: List contacts
      responses:
        '200':
          description: Contacts
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Contact' }
    post:
      summary: Create a contact
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContactInput' }
      responses:
        '201': { $ref: '#/components/responses/Created' }
  /deals:
    get:
      summary: List CRM deals
      responses:
        '200': { description: Deals }
    post:
      summary: Create a CRM deal
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DealInput' }
      responses:
        '201': { $ref: '#/components/responses/Created' }
  /uploads:
    post:
      summary: Compress and store an authenticated image
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Upload metadata
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Upload' }
        '413': { $ref: '#/components/responses/Problem' }
  /uploads/{key}:
    get:
      summary: Download a workspace image
      parameters:
        - in: path
          name: key
          required: true
          schema: { type: string }
      responses:
        '200': { description: File bytes }
        '404': { $ref: '#/components/responses/Problem' }
  /widget/public:
    get:
      security: []
      summary: Load public widget config or replies
      parameters:
        - $ref: '#/components/parameters/WidgetId'
      responses:
        '200': { description: Widget response }
    post:
      security: []
      summary: Visitor message, read state, typing state, contact profile or rating
      parameters:
        - $ref: '#/components/parameters/WidgetId'
      responses:
        '200': { description: State updated }
        '201': { description: Message created }
  /widget/public/upload:
    post:
      security: []
      summary: Upload an image from the website widget
      parameters:
        - $ref: '#/components/parameters/WidgetId'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Upload metadata
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Upload' }
  /email/send:
    post:
      summary: Send a localized transactional email through Unisender Go
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, subject, html, plaintext, language]
              properties:
                to: { type: string, format: email }
                subject: { type: string }
                html: { type: string }
                plaintext: { type: string }
                language: { $ref: '#/components/schemas/Locale' }
                idempotenceKey: { type: string }
      responses:
        '202': { description: Accepted by Unisender Go }
  /billing/checkout:
    post:
      summary: Create a hosted recurring-payment checkout
      description: Raw card data is entered only on the PCI DSS provider page and never sent to Tallker.
      responses:
        '201': { description: Hosted checkout created }
  /internal/trial-emails:
    post:
      security: []
      summary: Send trial-ending reminder batch
      responses:
        '200': { description: Reminder batch processed }
        '401': { description: Invalid cron secret }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    developerApiKey:
      type: oauth2
      description: Send a scoped Tallker API key as a Bearer token.
      flows:
        clientCredentials:
          tokenUrl: /api/v1/developer/api-keys
          scopes: { contacts:read: Read contacts, contacts:write: Write contacts, orders:read: Read orders, orders:write: Write orders, widget_identity:write: Create identity tokens }
  parameters:
    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 300 }
    ConversationId:
      in: path
      name: id
      required: true
      schema: { type: string, minLength: 1 }
    WidgetId:
      in: query
      name: id
      required: true
      schema: { type: string, minLength: 12 }
  responses:
    Session:
      description: Access and rotating refresh tokens
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Session' }
    Success:
      description: Success
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: { type: boolean, const: true }
    Created:
      description: Resource created
      content:
        application/json:
          schema:
            type: object
            properties:
              id: { type: string }
    Problem:
      description: API error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Problem' }
  schemas:
    ExternalContact:
      type: object
      required: [external_id]
      properties:
        external_id: { type: string, maxLength: 191 }
        external_user_id: { type: string, maxLength: 191 }
        widget_id: { type: string }
        name: { type: string, maxLength: 120 }
        email: { type: string, format: email }
        phone: { type: string }
        company: { type: string, maxLength: 160 }
        metadata: { type: object, additionalProperties: true }
    ExternalOrder:
      type: object
      required: [external_id, customer]
      properties:
        external_id: { type: string, maxLength: 191 }
        status: { type: string, enum: [new, processing, paid, shipping, completed, cancelled] }
        customer: { $ref: '#/components/schemas/ExternalContact' }
        amount: { type: number, minimum: 0 }
        currency: { type: string, minLength: 3, maxLength: 3 }
        items: { type: array, items: { type: object, additionalProperties: true } }
        created_at: { type: string, format: date-time }
        metadata: { type: object, additionalProperties: true }
    WidgetIdentity:
      type: object
      required: [external_user_id]
      properties:
        external_user_id: { type: string, maxLength: 191 }
        widget_id: { type: string }
        name: { type: string, maxLength: 120 }
        email: { type: string, format: email }
        phone: { type: string }
        company: { type: string, maxLength: 160 }
        metadata: { type: object, additionalProperties: true }
    Locale:
      type: string
      enum: [ru, en, ka, es, de, fr, pt, tr, ar, zh, ja, ko]
    RegisterRequest:
      type: object
      required: [email, password, displayName, workspaceName, phone]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password, minLength: 10 }
        displayName: { type: string, maxLength: 120 }
        phone: { type: string, minLength: 7, maxLength: 32 }
        siteUrl: { type: string, maxLength: 253, description: "Required when creating a new workspace; its normalized domain becomes the initial widget site." }
        workspaceName: { type: string, maxLength: 120 }
        language: { $ref: '#/components/schemas/Locale' }
    Session:
      type: object
      required: [accessToken, refreshToken, tokenType, expiresIn, workspaceId, userId]
      properties:
        accessToken: { type: string }
        refreshToken: { type: string }
        tokenType: { type: string, const: Bearer }
        expiresIn: { type: integer, const: 900 }
        workspaceId: { type: string }
        userId: { type: string }
        role: { type: string }
    Workspace:
      type: object
      properties:
        ID: { type: string }
        Name: { type: string }
        Plan: { type: string }
        Timezone: { type: string }
        Language: { $ref: '#/components/schemas/Locale' }
        TrialEndsAt: { type: string, format: date-time }
    Conversation:
      type: object
      required: [id, name, status, unread, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string }
        phone: { type: string }
        status: { type: string }
        unread: { type: integer }
        page: { type: string }
        source: { type: string }
        updatedAt: { type: string, format: date-time }
        assignedToMe: { type: boolean }
        visitorTyping: { type: boolean }
    ConversationPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Conversation' }
        nextCursor: { type: string }
        hasMore: { type: boolean }
    Message:
      type: object
      required: [id, sender, body, createdAt]
      properties:
        id: { type: string }
        sender: { type: string, enum: [visitor, operator, note] }
        body: { type: string }
        createdAt: { type: string, format: date-time }
        attachmentUrl: { type: string }
        attachmentName: { type: string }
        attachmentMime: { type: string }
        operatorName: { type: string }
        operatorAvatarUrl: { type: string }
    MessagePage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Message' }
        nextCursor: { type: string }
        hasMore: { type: boolean }
    SendMessageRequest:
      type: object
      required: [conversationId]
      properties:
        conversationId: { type: string }
        body: { type: string, maxLength: 10000 }
        attachmentKey: { type: string }
        attachmentName: { type: string }
        attachmentMime: { type: string }
        note: { type: boolean }
        idempotencyKey: { type: string, maxLength: 96 }
    SendMessageResponse:
      type: object
      required: [id, duplicate]
      properties:
        id: { type: string }
        duplicate: { type: boolean }
        createdAt: { type: string, format: date-time }
    RealtimeEvent:
      type: object
      required: [type, workspaceId, createdAt]
      properties:
        id: { type: string, description: Present for durable events. }
        type: { type: string }
        workspaceId: { type: string }
        conversationId: { type: string }
        createdAt: { type: string, format: date-time }
        payload: { type: object, additionalProperties: true }
    DeviceRegistration:
      type: object
      required: [platform]
      properties:
        platform: { type: string, enum: [android, windows] }
        provider: { type: string, enum: [none, fcm, wns], default: none }
        pushToken: { type: string, maxLength: 512 }
        deviceName: { type: string, maxLength: 160 }
        appVersion: { type: string, maxLength: 40 }
        locale: { type: string, maxLength: 16 }
    ContactInput:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 120 }
        email: { type: string, format: email }
        phone: { type: string, maxLength: 40 }
        company: { type: string, maxLength: 160 }
        source: { type: string, maxLength: 80 }
    Contact:
      allOf:
        - $ref: '#/components/schemas/ContactInput'
        - type: object
          properties:
            ID: { type: string }
            CreatedAt: { type: string, format: date-time }
    DealInput:
      type: object
      required: [title]
      properties:
        stageId: { type: string }
        title: { type: string, maxLength: 180 }
        contact: { type: string, maxLength: 120 }
        amount: { type: integer, minimum: 0 }
        source: { type: string, maxLength: 80 }
        nextAction: { type: string, maxLength: 120 }
    Upload:
      type: object
      properties:
        key: { type: string }
        url: { type: string }
        name: { type: string }
        mime: { type: string }
        size: { type: integer }
    Problem:
      type: object
      required: [error, status]
      properties:
        error: { type: string }
        status: { type: integer }
