asyncapi: 3.0.0
info:
  title: DialStack WebRTC Signalling Protocol
  version: 1.0.0
  description: |
    Real-time signalling protocol for DialStack WebRTC softphones.

    ## Overview

    The WebRTC signalling protocol enables softphone applications (web, mobile, desktop)
    to make and receive phone calls through DialStack. It handles call setup, media
    negotiation, call control, and presence — all over a single WebSocket connection.

    ## Connection Flow

    1. Obtain a user token via `POST /v1/user_sessions` (see [Authentication](/guides/authentication#user-tokens))
    2. Open a WebSocket connection to `wss://api.dialstack.ai/v1/webrtc`
    3. Send an `authenticate` message with the user token
    4. Receive an `authenticated` message confirming the session
    5. The connection is now ready for calls and presence

    ## Authentication

    The WebSocket connection itself does not use HTTP headers for authentication.
    Instead, the first message sent after connecting must be an `authenticate` message
    containing the user token. The server responds with `authenticated` on success
    or `error` on failure, closing the connection.

    ## Mid-session token refresh

    The user token has an expiry (`exp`). The server enforces it for the whole
    lifetime of the connection, not just at the handshake: when the token expires,
    the server closes the connection with a fatal `auth_expired` error. A long-lived
    WebSocket does **not** outlive its token.

    To keep the connection (and any in-progress calls) alive across an expiry, the
    client refreshes the token in-band **before** `exp` — mint a fresh token from
    `POST /v1/user_sessions` and send it as an `auth.refresh` message. The server
    re-verifies it and, on success, resets the expiry timer to the new token's `exp`
    and replies `auth.refreshed`. The WebSocket stays open; there is no reconnect and
    no call disruption.

    - The refreshed token **must resolve to the same user and account** as the
      original `authenticate`. A token for a different identity is rejected with a
      non-fatal `auth_failed`; the connection and its original expiry timer are
      unchanged (a refresh swaps the credential, it never switches identity).
    - An invalid or already-expired refresh token is rejected with a non-fatal
      `auth_failed` / `auth_expired`; the connection stays open and the **original**
      expiry timer keeps running — a failed refresh never extends the session.
    - If the client never refreshes in time, the connection is evicted with a fatal
      `auth_expired` at `exp`. Active calls survive the drop via the reconnection
      grace window (see *Reconnection* below): reconnect with a fresh token and the
      server replays `call.restored`.

    This refresh flow is independent of the keepalive ping/pong (see *Keepalive*):
    pings prove liveness, `auth.refresh` proves current authorization.

    ## Subprotocol negotiation

    The client MUST request the `dialstack.webrtc.v1` subprotocol on the
    HTTP upgrade via `Sec-WebSocket-Protocol`. A server that doesn't speak
    the offered version rejects the upgrade. Browser SDK:

    ```js
    new WebSocket('wss://api.dialstack.ai/v1/webrtc', ['dialstack.webrtc.v1']);
    ```

    When v2 ships, clients that want to remain backward-compatible can
    offer both: `['dialstack.webrtc.v2', 'dialstack.webrtc.v1']` and the
    server picks the highest one it supports.

    ## Keepalive

    Liveness is handled at the WebSocket protocol layer. The server sends a
    native WebSocket ping control frame every 30 seconds; the client's WS
    library responds with the matching pong transparently — no application
    message is required. If the server doesn't get a pong within ~10 seconds,
    it closes the connection with `idle_timeout`.

    ## Reconnection

    If the WebSocket connection drops:

    1. Wait with exponential backoff (1s, 2s, 4s, 8s, max 30s)
    2. Reconnect and re-authenticate
    3. Active calls survive brief disconnections (up to 30 seconds). The server
       holds call state and media continues flowing. On reconnection, the server
       sends `call.restored` for each active call so the client can rebuild its UI.

    ## Message Format

    All messages are JSON objects with a `type` field identifying the message kind.
    Messages from the client include a `type` and message-specific fields.
    Messages from the server include a `type` and message-specific fields.

    ## Call Lifecycle

    ### Outbound Call
    ```
    Client                          Server
      │  call.create (with sdp) ───>  │
      │  <────────────── call.trying  │
      │  <──────────── sdp.pranswer   │  (network early media; zero or more)
      │  <────────────── call.ringing │  (if the callee is alerting — independent
      │                               │   of early media; either order)
      │  ice.candidate ←──────────→   │  (bidirectional, multiple)
      │  ice.done ────────────────>   │
      │  <──────────────── ice.done   │
      │  <─────────────── sdp.answer  │  (final answer, when the call is answered)
      │  <──────────── call.answered  │
      │         ... call in progress ...
      │  call.hangup ─────────────>   │
      │  <────────────── call.ended   │
    ```

    The browser generates the SDP offer (via `RTCPeerConnection.createOffer()`)
    and sends it inside `call.create`. The browser cannot render any incoming
    audio until it applies an answer, so the server forwards SDP in two steps:

    - `sdp.pranswer` — a **provisional answer** carrying *network early media*
      (carrier ringback, announcements, "the number you dialed…"). Apply it with
      `setRemoteDescription({ type: 'pranswer', sdp })` so audio plays before the
      call is answered. Only sent when the network supplies early media, and the
      server may send it **more than once** (each early-media update) — apply
      each one as it arrives.
    - `sdp.answer` — the **final answer**, sent when the call is answered. Apply
      it with `setRemoteDescription({ type: 'answer', sdp })`. It *replaces* any
      provisional answer and may legitimately differ from it, so always apply it.

    `call.ringing` is emitted only when the callee's device is actually alerting,
    and is independent of early media — a call may have early media without
    ringing (e.g. an auto-attendant) or ringing without early media.

    ### Inbound Call
    ```
    Client                          Server
      │  <──────────── call.incoming  │
      │  <─────────────── sdp.offer   │
      │  call.answer ─────────────>   │
      │  sdp.answer ──────────────>   │
      │  ice.candidate ←──────────→   │  (bidirectional, multiple)
      │  <──────────── call.answered  │
      │         ... call in progress ...
      │  <────────────── call.ended   │
    ```

    ## Related Documentation
    - [REST API Reference](/api) — User sessions, ICE servers, User Profile endpoints
    - [WebRTC Guide](/webrtc) — Integration guide with code examples
    - [Mobile & Push Notifications](/webrtc/mobile) — Mobile incoming call notifications
    - [Download AsyncAPI Spec](/api/asyncapi-webrtc.yaml) — Raw YAML specification

  contact:
    name: DialStack API Support
    email: info@dialstack.ai
    url: https://docs.dialstack.ai

servers:
  dialstack:
    host: api.dialstack.ai
    pathname: /v1/webrtc
    protocol: wss
    description: |
      DialStack WebRTC signalling endpoint. Connect with a standard WebSocket client:
      ```javascript
      const ws = new WebSocket('wss://api.dialstack.ai/v1/webrtc');
      ```

channels:
  signalling:
    address: /v1/webrtc
    description: |
      Bidirectional signalling channel for WebRTC call setup, control, and presence.
      A single WebSocket connection handles all calls for the authenticated user,
      including multiple simultaneous calls.
    messages:
      # Connection lifecycle
      authenticate:
        name: authenticate
        title: Authenticate
        summary: First message after connection — authenticates the user
        contentType: application/json
        payload:
          $ref: '#/components/schemas/AuthenticateMessage'
      authenticated:
        name: authenticated
        title: Authenticated
        summary: Server confirms authentication and returns session info
        contentType: application/json
        payload:
          $ref: '#/components/schemas/AuthenticatedMessage'
      authRefresh:
        name: auth.refresh
        title: Refresh Token
        summary: Present a fresh token before the current one expires
        contentType: application/json
        payload:
          $ref: '#/components/schemas/AuthRefreshMessage'
      authRefreshed:
        name: auth.refreshed
        title: Token Refreshed
        summary: Server accepted the refreshed token and reset the expiry timer
        contentType: application/json
        payload:
          $ref: '#/components/schemas/AuthRefreshedMessage'
      ping:
        name: ping
        title: Ping
        summary: Application-layer liveness probe
        contentType: application/json
        payload:
          $ref: '#/components/schemas/PingMessage'
      pong:
        name: pong
        title: Pong
        summary: Server reply confirming the connection is alive
        contentType: application/json
        payload:
          $ref: '#/components/schemas/PongMessage'
      error:
        name: error
        title: Error
        summary: Protocol or call error
        contentType: application/json
        payload:
          $ref: '#/components/schemas/ErrorMessage'
      networkChanged:
        name: network.changed
        title: Network Changed
        summary: |
          Advisory that the bound emergency address no longer applies on the
          current network (E911). The session stays usable and emergency calls
          still go out; non-emergency PSTN is gated until an address valid here
          is confirmed.
        contentType: application/json
        payload:
          $ref: '#/components/schemas/NetworkChangedMessage'

      # Call signalling — client to server
      callCreate:
        name: call.create
        title: Create Call
        summary: Initiate an outbound call
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallCreateMessage'
      callAnswer:
        name: call.answer
        title: Answer Call
        summary: Answer an incoming call
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallAnswerMessage'
      callReject:
        name: call.reject
        title: Reject Call
        summary: Reject an incoming call
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallRejectMessage'
      callHangup:
        name: call.hangup
        title: Hang Up
        summary: End an active or ringing call
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallHangupMessage'

      # Call signalling — server to client
      callIncoming:
        name: call.incoming
        title: Incoming Call
        summary: An inbound call is arriving for this user
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallIncomingMessage'
      callTrying:
        name: call.trying
        title: Call Trying
        summary: Server is processing the outbound call request
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallTryingMessage'
      callRinging:
        name: call.ringing
        title: Call Ringing
        summary: Remote party's phone is ringing
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallRingingMessage'
      callAnswered:
        name: call.answered
        title: Call Answered
        summary: Call has been answered (media is flowing)
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallAnsweredMessage'
      callEnded:
        name: call.ended
        title: Call Ended
        summary: Call has ended
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallEndedMessage'
      callRestored:
        name: call.restored
        title: Call Restored
        summary: Active call restored after reconnection
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallRestoredMessage'

      # Media negotiation — bidirectional
      sdpOffer:
        name: sdp.offer
        title: SDP Offer
        summary: SDP offer for media negotiation
        contentType: application/json
        payload:
          $ref: '#/components/schemas/SdpOfferMessage'
      sdpProvisionalAnswer:
        name: sdp.pranswer
        title: SDP Provisional Answer
        summary: Provisional answer carrying network early media (before the call is answered)
        contentType: application/json
        payload:
          $ref: '#/components/schemas/SdpProvisionalAnswerMessage'
      sdpAnswer:
        name: sdp.answer
        title: SDP Answer
        summary: SDP answer for media negotiation
        contentType: application/json
        payload:
          $ref: '#/components/schemas/SdpAnswerMessage'
      iceCandidate:
        name: ice.candidate
        title: ICE Candidate
        summary: ICE candidate for connectivity checks
        contentType: application/json
        payload:
          $ref: '#/components/schemas/IceCandidateMessage'
      iceDone:
        name: ice.done
        title: ICE Done
        summary: All ICE candidates have been gathered
        contentType: application/json
        payload:
          $ref: '#/components/schemas/IceDoneMessage'

      # Call control — client to server
      callHold:
        name: call.hold
        title: Hold Call
        summary: Place an active call on hold
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallHoldMessage'
      callResume:
        name: call.resume
        title: Resume Call
        summary: Resume a held call
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallResumeMessage'
      callMute:
        name: call.mute
        title: Mute
        summary: Mute the microphone (server-side)
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallMuteMessage'
      callUnmute:
        name: call.unmute
        title: Unmute
        summary: Unmute the microphone (server-side)
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallUnmuteMessage'
      callTransfer:
        name: call.transfer
        title: Blind Transfer
        summary: Transfer the call to another destination
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallTransferMessage'
      callTransferAttended:
        name: call.transfer.attended
        title: Attended Transfer
        summary: Start or complete an attended (consultative) transfer
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallTransferAttendedMessage'

      # Call control — server to client
      callHeld:
        name: call.held
        title: Call Held
        summary: Call was placed on hold (by local or remote party)
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallHeldMessage'
      callResumed:
        name: call.resumed
        title: Call Resumed
        summary: Held call was resumed
        contentType: application/json
        payload:
          $ref: '#/components/schemas/CallResumedMessage'

      # Presence
      presenceSubscribe:
        name: presence.subscribe
        title: Subscribe to Presence
        summary: Subscribe to presence updates for users in the account
        contentType: application/json
        payload:
          $ref: '#/components/schemas/PresenceSubscribeMessage'
      presenceList:
        name: presence.list
        title: Presence Snapshot
        summary: Initial presence snapshot after subscribing
        contentType: application/json
        payload:
          $ref: '#/components/schemas/PresenceListMessage'
      presenceUpdate:
        name: presence.update
        title: Presence Update
        summary: A user's presence status has changed
        contentType: application/json
        payload:
          $ref: '#/components/schemas/PresenceUpdateMessage'

operations:
  # Connection lifecycle
  sendAuthenticate:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Authenticate connection
    description: |
      Must be the first message sent after the WebSocket connection is established.
      The server responds with `authenticated` on success or `error` on failure.
      If authentication fails, the server closes the connection.
    messages:
      - $ref: '#/channels/signalling/messages/authenticate'

  receiveAuthenticated:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Authentication confirmed
    description: |
      Confirms successful authentication. Contains session metadata and any
      active calls that were preserved during a reconnection.
    messages:
      - $ref: '#/channels/signalling/messages/authenticated'

  sendAuthRefresh:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Refresh the session token in-band
    description: |
      Sent on an already-authenticated connection to present a fresh user
      token shortly before the current token's expiry. The refreshed token
      must resolve to the same user and account as the original
      `authenticate`. On success the server resets the connection's expiry
      timer and replies with `auth.refreshed`; the WebSocket stays open and
      in-progress calls are unaffected. On failure (missing/invalid/expired
      token, or an identity that doesn't match the session) the server
      replies with a non-fatal `error` (`auth_failed` or `auth_expired`) and
      leaves the connection — and its original expiry timer — untouched.
    messages:
      - $ref: '#/channels/signalling/messages/authRefresh'

  receiveAuthRefreshed:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Token refresh accepted
    description: |
      Confirms the refreshed token was accepted and the expiry timer was
      reset to the new token's expiry. Echoes the `req_id` from the
      originating `auth.refresh`.
    messages:
      - $ref: '#/channels/signalling/messages/authRefreshed'

  sendPing:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Probe that the connection is still alive
    description: |
      An application-layer liveness probe. A browser cannot send WebSocket
      control-frame pings from JavaScript, so a client that suspects the
      connection may have silently died while the tab was backgrounded sends
      this data frame on resume and expects a `pong`. Unlike `auth.refresh`, it
      has no side effect — in particular it does not reset the session expiry
      timer. If no `pong` arrives promptly the client treats the connection as
      dead and reconnects.
    messages:
      - $ref: '#/channels/signalling/messages/ping'

  receivePong:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Liveness confirmed
    description: |
      Reply to a `ping`, echoing its `req_id`. Its arrival is proof the
      connection is alive.
    messages:
      - $ref: '#/channels/signalling/messages/pong'

  receiveError:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Error notification
    description: |
      Sent when an error occurs. The `code` field identifies the error type.
      Some errors are fatal (the server closes the connection after sending);
      others are informational (the connection remains open).
    messages:
      - $ref: '#/channels/signalling/messages/error'

  receiveNetworkChanged:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Emergency-address network advisory
    description: |
      Sent after `authenticated` when the emergency address presented on the
      handshake does not apply on the current network (E911). The session is
      unaffected for emergency calls; the app should prompt the user to confirm
      or register an address valid for this network before non-emergency calls.
    messages:
      - $ref: '#/channels/signalling/messages/networkChanged'

  # Outbound call
  sendCallCreate:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Initiate outbound call
    description: |
      Dials the specified destination. The client's SDP offer (from
      `RTCPeerConnection.createOffer()`) is carried in the `sdp` field. The
      server responds with `call.trying`, then `sdp.answer` once Asterisk
      accepts the SIP INVITE. ICE candidates are trickled in both directions
      to establish the media path.
    messages:
      - $ref: '#/channels/signalling/messages/callCreate'

  receiveCallTrying:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Outbound call is being processed
    description: |
      Confirms the server is processing the call request. The `call_id` in this
      message should be used for all subsequent messages about this call.
    messages:
      - $ref: '#/channels/signalling/messages/callTrying'

  receiveCallRinging:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Remote party is ringing
    description: |
      The destination phone is ringing. Play a ringback tone to the user.
    messages:
      - $ref: '#/channels/signalling/messages/callRinging'

  # Inbound call
  receiveCallIncoming:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Incoming call notification
    description: |
      An inbound call is arriving for this user. The message includes caller ID
      information and an `sdp.offer` follows immediately. The client should alert
      the user and, when they answer, respond with `call.answer` and `sdp.answer`.

      The ring duration is controlled by the upstream caller (their PBX, the
      Find Me / Follow Me chain, or the dial plan node that routed to this
      user). When the upstream gives up, the server sends `call.ended` with
      reason `no-answer`. There is no server-side cap on ring time — clients
      should not assume a specific timeout.
    messages:
      - $ref: '#/channels/signalling/messages/callIncoming'

  sendCallAnswer:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Answer incoming call
    description: |
      Accepts an incoming call. Must be followed by `sdp.answer` with the
      local session description.
    messages:
      - $ref: '#/channels/signalling/messages/callAnswer'

  sendCallReject:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Reject incoming call
    description: |
      Rejects an incoming call on this device. The call may continue
      ringing on the user's other devices.
    messages:
      - $ref: '#/channels/signalling/messages/callReject'

  # Call lifecycle
  receiveCallAnswered:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Call connected
    description: |
      The call has been answered and media is flowing. For outbound calls, this
      means the remote party picked up. For inbound calls, this confirms the
      answer was processed.
    messages:
      - $ref: '#/channels/signalling/messages/callAnswered'

  sendCallHangup:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: End a call
    description: |
      Ends an active or ringing call. The server confirms with `call.ended`.
    messages:
      - $ref: '#/channels/signalling/messages/callHangup'

  receiveCallEnded:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Call ended
    description: |
      The call has ended. The `reason` field indicates why (hangup, no-answer,
      busy, failed, transferred). After receiving this message, clean up any
      media resources for this call.
    messages:
      - $ref: '#/channels/signalling/messages/callEnded'

  receiveCallRestored:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Active call restored after reconnection
    description: |
      Sent after `authenticated` when reconnecting with active calls. The client
      should rebuild its UI for each restored call. An `sdp.offer` follows for
      re-establishing the media path.
    messages:
      - $ref: '#/channels/signalling/messages/callRestored'

  # Media negotiation
  sendSdpOffer:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Send SDP offer
    description: |
      Send a local session description offer. Used during call setup and
      mid-call renegotiation (e.g., codec change, hold with sendonly).
    messages:
      - $ref: '#/channels/signalling/messages/sdpOffer'

  receiveSdpOffer:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Receive SDP offer
    description: |
      Server sends an SDP offer for the client to answer. This happens for
      inbound calls (the browser is the called party), for the
      consultation leg of an attended transfer (the consult target is
      effectively a new inbound call to the transferring user), for
      `call.restored` (re-establishing media after a WS reconnect), and
      for any mid-call renegotiation initiated by the server. Outbound
      call setup uses the reverse direction: the client sends its offer
      inside `call.create` and receives `sdp.answer`.
    messages:
      - $ref: '#/channels/signalling/messages/sdpOffer'

  sendSdpAnswer:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Send SDP answer
    description: |
      Respond to an SDP offer with a local session description answer.
    messages:
      - $ref: '#/channels/signalling/messages/sdpAnswer'

  receiveSdpProvisionalAnswer:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Receive SDP provisional answer (network early media)
    description: |
      Before the call is answered, the server may send a provisional answer
      carrying network early media (e.g. carrier ringback or an announcement),
      and may send more than one as the early media changes. Apply each with
      `setRemoteDescription({ type: 'pranswer', sdp })` so audio plays during the
      ringing phase. The final `sdp.answer` replaces the last provisional when
      the call is answered.
    messages:
      - $ref: '#/channels/signalling/messages/sdpProvisionalAnswer'

  receiveSdpAnswer:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Receive SDP answer
    description: |
      Server responds to the client's SDP offer with the final answer. If a
      provisional answer (`sdp.pranswer`) was sent earlier, this replaces it
      and may legitimately differ — always apply it.
    messages:
      - $ref: '#/channels/signalling/messages/sdpAnswer'

  sendIceCandidate:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Send ICE candidate
    description: |
      Send a locally gathered ICE candidate. Candidates are trickled as they
      are discovered — send each one immediately for the fastest call setup.
    messages:
      - $ref: '#/channels/signalling/messages/iceCandidate'

  receiveIceCandidate:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Receive ICE candidate
    description: |
      Server sends a remote ICE candidate. Add it to the peer connection
      with `RTCPeerConnection.addIceCandidate()`.
    messages:
      - $ref: '#/channels/signalling/messages/iceCandidate'

  sendIceDone:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: ICE gathering complete
    description: |
      All local ICE candidates have been gathered. This signals the end of
      trickle ICE from the client side.
    messages:
      - $ref: '#/channels/signalling/messages/iceDone'

  receiveIceDone:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Remote ICE gathering complete
    description: |
      All remote ICE candidates have been sent. No more candidates will arrive
      for this call.
    messages:
      - $ref: '#/channels/signalling/messages/iceDone'

  # Call control
  sendCallHold:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Hold call
    description: |
      Places the call on hold. The server confirms with `call.held` and plays
      hold music to the remote party.
    messages:
      - $ref: '#/channels/signalling/messages/callHold'

  sendCallResume:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Resume call
    description: |
      Resumes a held call. The server confirms with `call.resumed`.
    messages:
      - $ref: '#/channels/signalling/messages/callResume'

  receiveCallHeld:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Call placed on hold
    description: |
      Confirms the call was placed on hold. The `held_by` field indicates
      whether the local user or the remote party initiated the hold.
    messages:
      - $ref: '#/channels/signalling/messages/callHeld'

  receiveCallResumed:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Call resumed
    description: |
      Confirms a held call was resumed. Media resumes flowing.
    messages:
      - $ref: '#/channels/signalling/messages/callResumed'

  sendCallMute:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Server-side mute
    description: |
      Mutes the user's audio at the server. The remote party hears silence.
      This is in addition to any client-side mute (disabling the microphone track).
      Server-side mute is authoritative — even if the client sends audio, it
      is not forwarded.
    messages:
      - $ref: '#/channels/signalling/messages/callMute'

  sendCallUnmute:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Server-side unmute
    description: |
      Unmutes the user's audio at the server.
    messages:
      - $ref: '#/channels/signalling/messages/callUnmute'

  sendCallTransfer:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Blind transfer
    description: |
      Transfers the call to another destination without consulting the transfer
      target first. The call is immediately redirected and this client's
      participation ends. The server sends `call.ended` with reason `transferred`.
    messages:
      - $ref: '#/channels/signalling/messages/callTransfer'

  sendCallTransferAttended:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Attended transfer
    description: |
      Performs an attended (consultative) transfer in two steps:

      **Step 1 — Consult** (`step: "consult"`):
      Send with a `destination` and an `sdp` offer (the consultation leg
      reuses the outbound call flow, so the client supplies its offer here
      exactly as it does in `call.create`). The server:
      1. Places the original call on hold (sends `call.held` with `held_by: "local"`)
      2. Initiates a new call to the transfer target using the supplied offer
      3. Sends `call.trying` with a **new `call_id`** for the consultation call
      4. Sends `sdp.answer` for the consultation call's media
      5. Normal call setup follows (`call.ringing`, `call.answered`)

      The client now has two calls: the original (held) and the consultation (active).

      **Step 2 — Complete** (`step: "complete"`):
      Send to bridge the original caller with the transfer target. The server:
      1. Sends `call.ended` with reason `transferred` for the **original** call
      2. Sends `call.ended` with reason `transferred` for the **consultation** call
      3. The original caller and transfer target are connected directly

      **Cancelling:**
      Send `call.hangup` for the **consultation** call. The server ends the
      consultation call and resumes the original call automatically.

      **Failure:**
      If the consultation call fails (busy, no-answer), the server ends the
      consultation call and resumes the original call automatically.
    messages:
      - $ref: '#/channels/signalling/messages/callTransferAttended'

  # Presence
  sendPresenceSubscribe:
    action: send
    channel:
      $ref: '#/channels/signalling'
    summary: Subscribe to presence updates
    description: |
      Subscribes to real-time presence updates for users in the account.
      The server responds with `presence.list` containing the current status
      of all users, then sends `presence.update` messages as statuses change.
    messages:
      - $ref: '#/channels/signalling/messages/presenceSubscribe'

  receivePresenceList:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Presence snapshot
    description: |
      Sent in response to `presence.subscribe`. Contains the current presence
      status of the subscribed users. Use this to populate the initial state of
      a buddy list or BLF panel.
    messages:
      - $ref: '#/channels/signalling/messages/presenceList'

  receivePresenceUpdate:
    action: receive
    channel:
      $ref: '#/channels/signalling'
    summary: Presence changed
    description: |
      A user's presence status has changed (e.g., went on a call, set DND,
      came online). Update the UI for the affected user.
    messages:
      - $ref: '#/channels/signalling/messages/presenceUpdate'

components:
  schemas:
    # --- Connection Lifecycle ---

    AuthenticateMessage:
      type: object
      description: Authenticates the WebSocket connection with a user token.
      required:
        - type
        - token
      properties:
        type:
          type: string
          const: authenticate
        req_id:
          type: string
          description: |
            Optional client-chosen request correlation token, recommended on
            every client→server frame. The server echoes it on exactly one
            frame — the direct reply to the request: `authenticated` for this
            frame, `call.trying` for call-creating frames (`call.create` and
            the `consult` step of `call.transfer.attended`), or `error` on
            failure. State events caused by a request (`call.held`,
            `call.ended`, …) never carry a `req_id` — they can also occur for
            reasons other than your request.
        token:
          type: string
          description: User token obtained from `POST /v1/user_sessions`
          examples:
            - eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
        emergency_address_id:
          type: string
          description: |
            Optional. The emergency-address resource id (from
            `POST /v1/me/emergency-addresses`) this softphone uses for E911.
            When present and the connection's network matches the address, the
            server uses it to route emergency calls with a dispatchable
            location. If the network no longer matches, the server replies (in
            addition to `authenticated`) with a `network.changed` message and
            the address is not used until re-confirmed. Emergency calls
            (911/933) are never blocked, with or without an address.
          examples:
            - emerg_01h2xcejqtf2nbrexx3vqjhp42

    AuthenticatedMessage:
      type: object
      description: |
        Confirms successful authentication. Includes session and connection
        metadata.
      required:
        - type
        - user_id
        - account_id
      properties:
        type:
          type: string
          const: authenticated
        req_id:
          type: string
          description: Echoes the `req_id` from the originating `authenticate` frame, when present.
        user_id:
          type: string
          description: Authenticated user identifier
          examples:
            - user_01h2xcejqtf2nbrexx3vqjhp42
        account_id:
          type: string
          description: Account identifier
          examples:
            - acct_01h2xcejqtf2nbrexx3vqjhp41

    AuthRefreshMessage:
      type: object
      description: |
        Presents a fresh user token on an already-authenticated connection so
        the server can reset the session's expiry timer without dropping the
        WebSocket. The token must resolve to the same user and account as the
        original `authenticate`.
      required:
        - type
        - token
      properties:
        type:
          type: string
          const: auth.refresh
        req_id:
          type: string
          description: |
            Optional client-chosen correlation token. The server echoes it on
            the `auth.refreshed` reply (or on the `error` reply if the refresh
            is rejected).
        token:
          type: string
          description: A fresh user token obtained from `POST /v1/user_sessions`
          examples:
            - eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

    AuthRefreshedMessage:
      type: object
      description: |
        Confirms a successful token refresh. The connection's expiry timer has
        been reset to the new token's expiry; the WebSocket stays open.
      required:
        - type
      properties:
        type:
          type: string
          const: auth.refreshed
        req_id:
          type: string
          description: Echoes the `req_id` from the originating `auth.refresh` frame, when present.

    PingMessage:
      type: object
      description: |
        An application-layer liveness probe sent on an already-authenticated
        connection. The server replies with `pong` and takes no other action —
        it does not reset the session expiry timer.
      required:
        - type
      properties:
        type:
          type: string
          const: ping
        req_id:
          type: string
          description: Optional client-chosen correlation token, echoed on the `pong` reply.

    PongMessage:
      type: object
      description: Reply to a `ping`. Its arrival confirms the connection is alive.
      required:
        - type
      properties:
        type:
          type: string
          const: pong
        req_id:
          type: string
          description: Echoes the `req_id` from the originating `ping` frame, when present.

    NetworkChangedMessage:
      type: object
      description: |
        Advisory that the bound emergency address no longer applies on the
        current network (E911). Carries no fields beyond the discriminator.
      required:
        - type
      properties:
        type:
          type: string
          const: network.changed

    ErrorMessage:
      type: object
      description: |
        Error notification. Fatal errors close the connection after sending.
      required:
        - type
        - code
        - message
      properties:
        type:
          type: string
          const: error
        req_id:
          type: string
          description: |
            Echoes the `req_id` of the originating client frame when the
            error is in response to one. Absent on server-initiated errors
            (e.g. `going_away`, `session_limit`, `idle_timeout`).
        code:
          type: string
          description: Machine-readable error code
          enum:
            - auth_failed
            - auth_expired
            - invalid_message
            - call_failed
            - call_not_found
            - emergency_address_required
            - session_limit
            - session_replaced
            - session_revoked
            - rate_limited
            - internal_error
            - going_away
            - idle_timeout
            - slow_consumer
            - presence_unavailable
            - reachability_unavailable
        message:
          type: string
          description: Human-readable error description
          examples:
            - 'Invalid or expired user token'
        fatal:
          type: boolean
          description: If `true`, the server will close the connection after this message
          default: false
        context:
          type: object
          description: |
            Optional per-concern detail; which fields are present depends on
            `code`. Omitted when the error has no structured detail.
          properties:
            users:
              type: array
              items:
                type: string
              description: |
                For `presence_unavailable`: the requested users whose presence
                could not be established. They are absent from the snapshot. The
                client may retry these.

                For `reachability_unavailable`: users who ARE in the snapshot and
                whose status is live, but for whom we can no longer tell whether a
                device is connected — so they will not be reported `offline` while
                that lasts. Re-subscribing re-attempts it.

    # --- Call Signalling (Client → Server) ---

    CallCreateMessage:
      type: object
      description: |
        Initiates an outbound call. The `destination` can be a phone number
        (E.164 format) or an extension dial code.

        The client generates a WebRTC SDP offer (`RTCPeerConnection.createOffer()`)
        and passes it in `sdp`. The server replies with `sdp.answer` once
        Asterisk accepts the SIP INVITE. ICE candidates are trickled
        bidirectionally as they're gathered.
      required:
        - type
        - destination
        - sdp
      properties:
        type:
          type: string
          const: call.create
        req_id:
          type: string
          description: |
            Optional request correlation token. Echoed on this frame's direct
            reply — `call.trying` on success or `error` on failure — so the
            client can bind its provisional call object to the
            server-assigned `call_id`.
          examples:
            - req_a1b2c3d4
        destination:
          type: string
          description: |
            Phone number in E.164 format (e.g., `+14155551234`) or an extension
            dial code (e.g., `105`).
          examples:
            - '+14155551234'
            - '105'
        sdp:
          type: string
          description: |
            WebRTC SDP offer string from `RTCPeerConnection.createOffer()`.
            Becomes the body of the SIP INVITE the server sends to Asterisk;
            the answer comes back as `sdp.answer`.
        caller_id:
          type: ['string', 'null']
          description: |
            Caller ID number to display (E.164 format). Must be a phone number
            assigned to the account. If omitted, the account's default outbound
            caller ID is used.
          examples:
            - '+14155559876'

    CallAnswerMessage:
      type: object
      description: Accepts an incoming call.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.answer
        call_id:
          type: string
          description: Call identifier from the `call.incoming` message
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    CallRejectMessage:
      type: object
      description: |
        Rejects an incoming call on this device. The call may continue
        ringing on the user's other devices.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.reject
        call_id:
          type: string
          description: Call identifier from the `call.incoming` message
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        reason:
          type: string
          enum: [busy, decline]
          description: |
            Rejection reason:
            - `busy` — signal busy to the caller
            - `decline` — silently decline (no busy signal)
          default: decline

    CallHangupMessage:
      type: object
      description: Ends an active or ringing call.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.hangup
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    # --- Call Signalling (Server → Client) ---

    CallIncomingMessage:
      type: object
      description: |
        An inbound call is arriving. An `sdp.offer` message follows immediately
        with the remote session description.
      required:
        - type
        - call_id
        - from
        - to
      properties:
        type:
          type: string
          const: call.incoming
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        from:
          type: string
          description: Caller's phone number (E.164 format)
          examples:
            - '+14155551234'
        from_name:
          type: ['string', 'null']
          description: Caller's display name (from caller ID, if available)
          examples:
            - 'John Smith'
        to:
          type: string
          description: Called number or extension
          examples:
            - '+14155559876'

    CallTryingMessage:
      type: object
      description: |
        Server is processing the outbound call. Contains the server-assigned
        `call_id` to use for all subsequent messages about this call.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.trying
        call_id:
          type: string
          description: Server-assigned call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        req_id:
          type: ['string', 'null']
          description: |
            Echoes the `req_id` of the frame that created this call
            (`call.create` or the `consult` step of `call.transfer.attended`).
            `call.trying` is that frame's direct reply.

    CallRingingMessage:
      type: object
      description: The remote party's phone is ringing.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.ringing
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    CallAnsweredMessage:
      type: object
      description: |
        The call has been answered and media is flowing. For outbound calls,
        this means the remote party picked up. For inbound calls, this confirms
        the client's `call.answer` was processed.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.answered
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        answered_at:
          type: string
          format: date-time
          description: When the call was answered (ISO 8601)

    CallEndedMessage:
      type: object
      description: |
        The call has ended. Clean up media resources (close `RTCPeerConnection`).
      required:
        - type
        - call_id
        - reason
      properties:
        type:
          type: string
          const: call.ended
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        reason:
          type: string
          enum: [hangup, no-answer, busy, failed, transferred, rejected]
          description: |
            Why the call ended:
            - `hangup` — either party hung up
            - `no-answer` — timed out without answer
            - `busy` — destination returned busy
            - `failed` — call setup failed (network error, invalid destination)
            - `transferred` — call was transferred to another destination
            - `rejected` — the remote party rejected the call
        duration_seconds:
          type: ['integer', 'null']
          description: Call duration in seconds (null if never answered)

    CallRestoredMessage:
      type: object
      description: |
        Sent after `authenticated` when reconnecting with an active call that
        was preserved during the disconnection (up to 30 seconds). An `sdp.offer`
        follows for re-establishing the media path.

        The client should close any existing `RTCPeerConnection` for this call
        and create a new one. Then handle the `sdp.offer` normally (set remote
        description, create answer, exchange ICE candidates).
      required:
        - type
        - call_id
        - state
        - from
        - to
      properties:
        type:
          type: string
          const: call.restored
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        state:
          type: string
          enum: [ringing, active, held]
          description: Current state of the restored call
        from:
          type: string
          description: Caller's phone number
        from_name:
          type: ['string', 'null']
          description: Caller's display name
        to:
          type: string
          description: Called number or extension
        direction:
          type: string
          enum: [inbound, outbound]
        answered_at:
          type: ['string', 'null']
          format: date-time
          description: When the call was answered (null if still ringing)

    # --- Media Negotiation ---

    SdpOfferMessage:
      type: object
      description: |
        SDP offer for media negotiation. Sent by the server for inbound calls
        and outbound call setup. Sent by the client during renegotiation.
      required:
        - type
        - call_id
        - sdp
      properties:
        type:
          type: string
          const: sdp.offer
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        sdp:
          type: string
          description: |
            SDP (Session Description Protocol) offer string. Pass this to
            `RTCPeerConnection.setRemoteDescription()` as an offer.

    SdpAnswerMessage:
      type: object
      description: |
        SDP answer for media negotiation. Sent by the client in response to
        an `sdp.offer`. The SDP should be obtained from
        `RTCPeerConnection.createAnswer()`.
      required:
        - type
        - call_id
        - sdp
      properties:
        type:
          type: string
          const: sdp.answer
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        sdp:
          type: string
          description: |
            SDP answer string from `RTCPeerConnection.createAnswer()`. Pass
            the received offer to `setRemoteDescription()` first, then create
            and send the answer.

    SdpProvisionalAnswerMessage:
      type: object
      description: |
        SDP provisional answer carrying network early media, sent by the server
        during outbound call setup before the call is answered (from a SIP 18x
        with SDP, e.g. 183 Session Progress). Apply it with
        `setRemoteDescription({ type: 'pranswer', sdp })` so early media (carrier
        ringback, announcements) plays during ringing. The server may send more
        than one as the early media changes; apply each. The final `sdp.answer`
        replaces the last provisional when the call is answered and may
        legitimately differ (e.g. early media is send-only, the answered call is
        send/receive).
      required:
        - type
        - call_id
        - sdp
      properties:
        type:
          type: string
          const: sdp.pranswer
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        sdp:
          type: string
          description: |
            SDP provisional-answer string. Pass to
            `RTCPeerConnection.setRemoteDescription()` with `type: 'pranswer'`.

    IceCandidateMessage:
      type: object
      description: |
        ICE candidate for connectivity checks. Trickled bidirectionally as
        candidates are gathered. Each candidate should be added to the peer
        connection immediately with `RTCPeerConnection.addIceCandidate()`.
      required:
        - type
        - call_id
        - candidate
      properties:
        type:
          type: string
          const: ice.candidate
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        candidate:
          type: string
          description: |
            ICE candidate string from the `RTCIceCandidate.candidate` property.
          examples:
            - 'candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 192.168.1.100 rport 44323'
        sdp_mid:
          type: ['string', 'null']
          description: Media stream identification tag
          examples:
            - '0'
        sdp_m_line_index:
          type: ['integer', 'null']
          description: Index of the media description in the SDP

    IceDoneMessage:
      type: object
      description: |
        All ICE candidates have been gathered. No more `ice.candidate` messages
        will be sent for this call from this side.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: ice.done
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    # --- Call Control ---

    CallHoldMessage:
      type: object
      description: Places the specified call on hold.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.hold
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    CallResumeMessage:
      type: object
      description: Resumes a held call.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.resume
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    CallHeldMessage:
      type: object
      description: Confirms the call was placed on hold.
      required:
        - type
        - call_id
        - held_by
      properties:
        type:
          type: string
          const: call.held
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        held_by:
          type: string
          enum: [local, remote]
          description: |
            Who initiated the hold:
            - `local` — this user placed the call on hold
            - `remote` — the remote party placed the call on hold

    CallResumedMessage:
      type: object
      description: Confirms a held call was resumed.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.resumed
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    CallMuteMessage:
      type: object
      description: |
        Mutes the user's audio at the server. The remote party hears silence
        regardless of whether the client is sending audio.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.mute
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    CallUnmuteMessage:
      type: object
      description: Unmutes the user's audio at the server.
      required:
        - type
        - call_id
      properties:
        type:
          type: string
          const: call.unmute
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45

    CallTransferMessage:
      type: object
      description: |
        Blind transfer — immediately redirects the call to another destination.
        The current client's participation ends after the transfer.
      required:
        - type
        - call_id
        - destination
      properties:
        type:
          type: string
          const: call.transfer
        call_id:
          type: string
          description: Call identifier
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        destination:
          type: string
          description: Transfer destination (E.164 number or extension)
          examples:
            - '105'
            - '+14155551234'

    CallTransferAttendedMessage:
      type: object
      description: Attended (consultative) transfer. Send with step "consult" to dial the target, then "complete" to bridge the calls.
      required:
        - type
        - call_id
        - step
      properties:
        type:
          type: string
          const: call.transfer.attended
        call_id:
          type: string
          description: |
            For `consult`: the call identifier of the original call to be transferred.
            For `complete`: the call identifier of the original call.
          examples:
            - call_01h2xcejqtf2nbrexx3vqjhp45
        step:
          type: string
          enum: [consult, complete]
          description: |
            Transfer step:
            - `consult` — hold the current call and dial the transfer target
            - `complete` — connect the original caller to the transfer target
        destination:
          type: string
          description: |
            Transfer destination (required for `consult` step, ignored for `complete`).
            E.164 number or extension.
          examples:
            - '105'
        sdp:
          type: string
          description: |
            SDP offer for the consultation leg (required for `consult` step,
            ignored for `complete`). The consultation reuses the outbound call
            flow, so the client supplies its offer here as it does in
            `call.create`; the server replies with `sdp.answer`.
        req_id:
          type: string
          description: |
            Optional request correlation token. The `consult` step is a
            call-creating frame, so its direct reply is the consultation's
            `call.trying`, which echoes this value — exactly like
            `call.create`. On failure, the `error` frame echoes it instead.
          examples:
            - req_lx3k9f_2

    # --- Presence ---

    PresenceSubscribeMessage:
      type: object
      description: |
        Subscribe to presence updates for an explicit set of users. The server
        responds with `presence.list` (a snapshot of the requested users), then
        sends `presence.update` messages as their statuses change. `users` is
        required and bounded — an empty or over-limit list is rejected with an
        `error` frame (`invalid_message`). There is no whole-account default.
      required:
        - type
        - users
      properties:
        type:
          type: string
          const: presence.subscribe
        req_id:
          type: string
          description: |
            Optional client-supplied correlation id. When present, it is echoed
            on the `presence.list` reply so the client can match the snapshot to
            this request.
        users:
          type: array
          items:
            type: string
          minItems: 1
          maxItems: 100
          description: |
            The users to watch — an explicit, bounded list (max 100). Required.
            An empty or over-limit list is rejected with an `invalid_message`
            error rather than defaulting to the whole account.

    PresenceListMessage:
      type: object
      description: |
        Presence snapshot of all subscribed users. Sent in response to
        `presence.subscribe`.
      required:
        - type
        - users
      properties:
        type:
          type: string
          const: presence.list
        req_id:
          type: string
          description: Echoes the `req_id` of the originating `presence.subscribe` frame, when present.
        users:
          type: array
          items:
            $ref: '#/components/schemas/PresenceEntry'
          description: Current presence status for each subscribed user

    PresenceUpdateMessage:
      type: object
      description: A user's presence status has changed.
      required:
        - type
        - user_id
        - status
      properties:
        type:
          type: string
          const: presence.update
        user_id:
          type: string
          description: User whose presence changed
          examples:
            - user_01h2xcejqtf2nbrexx3vqjhp42
        status:
          type: string
          enum: [available, on_call, dnd, away, offline]
          description: |
            The user's new status. `on_call` = the user is on a call;
            `available` = the user can take a call; `offline` = the user has no
            device connected, so they cannot be reached. `dnd` and `away` are
            reserved and not sent.
        status_text:
          type: ['string', 'null']
          description: Optional custom status message
        updated_at:
          type: string
          format: date-time

    PresenceEntry:
      type: object
      description: Presence status for a single user.
      required:
        - user_id
        - name
        - status
      properties:
        user_id:
          type: string
          description: User identifier
          examples:
            - user_01h2xcejqtf2nbrexx3vqjhp42
        name:
          type: string
          description: User's display name
          examples:
            - 'Jane Doe'
        status:
          type: string
          enum: [available, on_call, dnd, away, offline]
          description: |
            The user's current status in the snapshot. `on_call` = the user is on
            a call; `available` = the user can take a call; `offline` = the user
            has no device connected, so they cannot be reached. A requested user
            whose presence could not be looked up is left out of the snapshot and
            reported in a non-fatal `presence_unavailable` error, which the client
            may retry — that is different from `offline`, which is a real answer.
            A user named in a non-fatal `reachability_unavailable` error is in the
            snapshot and their status is real, but `offline` will not be reported
            for them until that watch recovers. `dnd` and `away` are reserved and
            not sent.
        status_text:
          type: ['string', 'null']
          description: Optional custom status message
        updated_at:
          type: string
          format: date-time
