openapi: 3.0.3
info:
  title: Umroh Haji Broadcaster API
  description: |
    REST API for managing users, roles, and audio livestream rooms.

    ## Company Switcher Header

    Authenticated endpoints that deal with company-scoped data accept an optional
    `X-Company-ID` header. Setting this header narrows all subsequent requests to
    that company — equivalent to switching companies in the web app.

    ```
    X-Company-ID: 42
    ```

    Rules:
    - The company must be one the caller belongs to, otherwise `403` is returned.
    - `0` or omitting the header means "all companies" (default behaviour).
    - An explicit `company_id` body field or query parameter always overrides the header.
    - The header is validated by the `ResolveCurrentCompany` middleware on every
      protected route before the controller runs.
  version: 1.0.0

servers:
  - url: /api
    description: API Server

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Sanctum token
    guestBearerAuth:
      type: http
      scheme: bearer
      bearerFormat: LiveKit guest JWT
      description: |
        The LiveKit participant token returned by Guest Join. Accepted in
        place of a Sanctum token by endpoints a guest is scoped to — the
        token's room claim must match the room in the URL, and the guest
        session must not have been removed (e.g. kicked) since joining.

  parameters:
    CompanyIdHeader:
      name: X-Company-ID
      in: header
      required: false
      schema:
        type: integer
        example: 42
      description: |
        Scope this request to a specific company (company switcher).
        Must be a company the caller belongs to — otherwise `403` is returned.
        `0` or omitting the header means no company filter (all companies).
        An explicit `company_id` body field or query parameter takes precedence over this header.

  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: John Doe
        email:
          type: string
          format: email
          example: john@example.com
        email_verified_at:
          type: string
          format: date-time
          nullable: true
        avatar_url:
          type: string
          nullable: true
          example: https://example.com/storage/avatars/abc123.jpg
        roles:
          type: array
          items:
            type: string
          example: [USER]
        permissions:
          type: array
          items:
            type: string
          example: [rooms.create, rooms.join]
        companies:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
          example: [{id: 1, name: Aksa Umroh}]
        created_at:
          type: string
          format: date-time

    AuthResponse:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/User'
        token:
          type: string
          example: 1|plainTextToken...

    PersonalAccessToken:
      type: object
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: api
        user_agent:
          type: string
          nullable: true
          example: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)
        firebase_device_token:
          type: string
          nullable: true
          example: fCm_device_token_abc123
        last_used_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
        is_current:
          type: boolean
          description: Whether this token is the one used for the current request

    Notification:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: 9d1f2e3a-4b5c-4d6e-8f7a-1b2c3d4e5f6a
        type:
          type: string
          description: Fully-qualified class name of the notification
          example: App\Notifications\WalletTopUpRequested
        data:
          type: object
          description: |
            Notification payload, shape depends on `type`. Always includes
            `title`, `message`, and `action_url`. Known types:
            - `App\Notifications\WalletTopUpRequested`: adds `wallet_transaction_id`, `company_id`, `amount`
            - `App\Notifications\WalletTopUpReviewed`: adds `wallet_transaction_id`, `status`
          additionalProperties: true
          example:
            title: New wallet top-up request
            message: Ahmad requested Rp50.000 for Umroh Travel Co.
            action_url: /companies/1
            wallet_transaction_id: 7
            company_id: 1
            amount: 50000
        read_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    MessageResponse:
      type: object
      properties:
        message:
          type: string

    ValidationError:
      type: object
      properties:
        message:
          type: string
          example: The email field is required.
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          example:
            email:
              - The email field is required.

    AppPageListItem:
      type: object
      properties:
        slug:
          type: string
          example: help-center
        title:
          type: string
          example: Pusat Bantuan
        is_active:
          type: boolean
          example: true

    AppPageDetail:
      type: object
      properties:
        slug:
          type: string
          example: terms
        title:
          type: string
          example: Syarat & Ketentuan
        content:
          type: string
          description: HTML content
          example: "<p>Terms and conditions content...</p>"
        is_active:
          type: boolean
          example: true

    AppVersion:
      type: object
      properties:
        ios:
          type: object
          properties:
            latest:
              type: string
              nullable: true
              example: "2.1.0"
            minimum:
              type: string
              nullable: true
              example: "1.8.0"
        android:
          type: object
          properties:
            latest:
              type: string
              nullable: true
              example: "2.1.0"
            minimum:
              type: string
              nullable: true
              example: "1.8.0"

    CompanySubscription:
      type: object
      nullable: true
      properties:
        id:
          type: integer
          example: 3
        status:
          type: string
          example: active
        plan:
          nullable: true
          type: object
          properties:
            id:
              type: integer
              example: 2
            name:
              type: string
              example: Basic

    CompanySubscriptionWithFeatures:
      type: object
      nullable: true
      properties:
        id:
          type: integer
          example: 3
        status:
          type: string
          example: active
        plan:
          nullable: true
          type: object
          properties:
            id:
              type: integer
              example: 2
            name:
              type: string
              example: Basic
            features:
              type: array
              items:
                type: object
                properties:
                  feature_key:
                    type: string
                    example: max_rooms
                  feature_value:
                    type: integer
                    description: -1 means unlimited
                    example: 5

    Company:
      type: object
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Aksa Travel
        address:
          type: string
          nullable: true
          example: Jl. Merdeka No. 1, Jakarta
        description:
          type: string
          nullable: true
          example: Travel umroh terpercaya
        phone:
          type: string
          nullable: true
          example: "+6281234567890"
        image_url:
          type: string
          format: uri
          nullable: true
          example: https://example.com/storage/companies/logo.jpg
        is_active:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
        subscription:
          $ref: '#/components/schemas/CompanySubscription'

    CompanyDetail:
      allOf:
        - $ref: '#/components/schemas/Company'
        - type: object
          properties:
            subscription:
              $ref: '#/components/schemas/CompanySubscriptionWithFeatures'

    CompanyInviteCode:
      type: object
      properties:
        code:
          type: string
          example: aB3xZ9mQ
        expires_at:
          type: string
          format: date-time
        qr_code:
          type: string
          format: uri
          example: https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=aB3xZ9mQ

    RoomCompany:
      type: object
      description: Company that owns the room. Present when the company relation is loaded.
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Aksa Travel Umroh
        subscription:
          description: Active subscription. Only present on room detail (Show Room). Null when the company has no active subscription.
          nullable: true
          type: object
          properties:
            id:
              type: integer
              example: 3
            status:
              type: string
              example: active
            plan:
              nullable: true
              type: object
              properties:
                id:
                  type: integer
                  example: 2
                name:
                  type: string
                  example: Basic
                features:
                  type: array
                  items:
                    type: object
                    properties:
                      feature_key:
                        type: string
                        example: max_rooms
                      feature_value:
                        type: integer
                        description: -1 means unlimited
                        example: 5

    Room:
      type: object
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Haji 2025 Group A
        short_code:
          type: string
          example: A1B2C3D4E5
        description:
          type: string
          nullable: true
          example: null
        status:
          type: string
          enum: [active, scheduled, closed]
          example: active
        qr_code:
          type: string
          format: uri
          example: https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=haji-2025-group-a
        company_id:
          type: integer
          example: 1
        company:
          $ref: '#/components/schemas/RoomCompany'
        created_by:
          type: integer
          nullable: true
          example: 1
        created_at:
          type: string
          format: date-time
        archived_at:
          type: string
          format: date-time
          nullable: true
          description: Set when the room has been archived. Null means not archived.
          example: null
        livekit_room:
          type: object
          nullable: true

    RoomParticipantLimited:
      description: Participant fields visible to all authenticated room members.
      type: object
      properties:
        id:
          type: integer
          example: 5
        name:
          type: string
          example: Budi Santoso
        avatar:
          type: string
          nullable: true
          example: https://example.com/avatars/budi.jpg
        role:
          type: string
          enum: [muthowwif, participant]
          example: participant

    RoomParticipantFull:
      description: Full participant data visible to superadmin and admin (rooms.view-managed).
      allOf:
        - $ref: '#/components/schemas/RoomParticipantLimited'
        - type: object
          properties:
            email:
              type: string
              format: email
              example: budi@example.com
            roles:
              type: array
              items:
                type: string
              example: [USER]
            joined_at:
              type: string
              format: date-time
              nullable: true

    Subscription:
      type: object
      properties:
        id:
          type: integer
          example: 3
        company_id:
          type: integer
          example: 1
        plan:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Basic
            plan_type:
              type: string
              example: quota_fixed
            plan_type_label:
              type: string
              example: Quota Fixed
            billing_cycle:
              type: string
              example: monthly
            billing_cycle_label:
              type: string
              example: Monthly
        status:
          type: string
          enum: [pending, active, trialing, past_due, expired, cancelled]
          example: trialing
        status_label:
          type: string
          example: Trialing
        starts_at:
          type: string
          format: date-time
        trial_ends_at:
          type: string
          format: date-time
          nullable: true
        ends_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    RoomSessionParticipant:
      type: object
      properties:
        id:
          type: integer
          example: 1
        identity:
          type: string
          example: "42"
        name:
          type: string
          nullable: true
          description: |
            Resolved from `identity` — the User's name (participant_type = user) or
            RoomGuest's submitted nickname (participant_type = guest). Null if the
            underlying record no longer exists (deleted user, pruned guest row).
          example: Budi Santoso
        participant_type:
          type: string
          example: user
        is_publisher:
          type: boolean
          example: false
        joined_at:
          type: string
          format: date-time
        left_at:
          type: string
          format: date-time
          nullable: true
        duration_seconds:
          type: integer
          nullable: true
          example: 3600
        disconnect_reason:
          type: string
          nullable: true

    RoomSession:
      type: object
      properties:
        id:
          type: integer
          example: 1
        livekit_sid:
          type: string
          example: RM_abc12345
        started_at:
          type: string
          format: date-time
        ended_at:
          type: string
          format: date-time
          nullable: true
        duration_seconds:
          type: integer
          nullable: true
          example: 3600
        peak_participants:
          type: integer
          example: 25
        total_participants:
          type: integer
          example: 50
        participant_minutes:
          type: number
          format: float
          example: 120.5
        participants:
          type: array
          description: Only present in show session (GET /rooms/{short_code}/sessions/{session})
          items:
            $ref: '#/components/schemas/RoomSessionParticipant'

    RoomGuest:
      type: object
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Tamu Anonim
        identity:
          type: string
          description: Unique identity string used to identify the guest in LiveKit
          example: guest_550e8400-e29b-41d4-a716-446655440000
        joined_at:
          type: string
          format: date-time
          description: When the guest record was created (first join)
        is_listening:
          type: boolean
          description: |
            Whether this guest's identity is currently connected (no `left_at`) to the
            room's active broadcast session, per LiveKit webhook-driven presence data.
            A guest can exist here (they requested a join token at some point) without
            ever being `true` — e.g. if they never actually connected, lost the
            connection, or already left.
          example: false

    GuestRoomPreview:
      type: object
      description: |
        Side-effect-free room preview for an anonymous guest deciding whether
        to join. Deliberately excludes everything the authenticated Room
        schema exposes to members (created_by, subscription details,
        livekit_room, etc).
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Kajian Pagi
        short_code:
          type: string
          example: A1B2C3D4E5
        status:
          type: string
          example: active
        image_url:
          type: string
          nullable: true
        company_name:
          type: string
          nullable: true
          example: Aksa Travel Umroh
        muthowwif_name:
          type: string
          nullable: true
          example: Ustadz Ahmad
        attendee_count:
          type: integer
          description: Combined count of participants and guests
          example: 12

    PaginationMeta:
      type: object
      description: Laravel's default paginator meta block.
      properties:
        current_page:
          type: integer
          example: 1
        from:
          type: integer
          nullable: true
          example: 1
        last_page:
          type: integer
          example: 5
        path:
          type: string
          example: https://example.com/api/rooms/A1B2C3D4E5/muthowwifs
        per_page:
          type: integer
          example: 15
        to:
          type: integer
          nullable: true
          example: 15
        total:
          type: integer
          example: 68

    PaginationLinks:
      type: object
      description: Laravel's default paginator links block.
      properties:
        first:
          type: string
          nullable: true
        last:
          type: string
          nullable: true
        prev:
          type: string
          nullable: true
        next:
          type: string
          nullable: true

    RoomUsage:
      type: object
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Kajian Subuh
        short_code:
          type: string
          example: A1B2C3D4E5
        total_sessions:
          type: integer
          example: 12
        total_duration_seconds:
          type: integer
          example: 43200
        total_participant_minutes:
          type: number
          format: float
          example: 1560.5
        avg_duration_seconds:
          type: number
          format: float
          nullable: true
          example: 3600.0

    CompanyUsage:
      type: object
      properties:
        company_id:
          type: integer
          example: 1
        company_name:
          type: string
          example: Aksa Travel Umroh
        room_count:
          type: integer
          description: |
            Number of rooms included in this rollup. For a Muthowwif caller this is
            narrowed to only the rooms they host, not the company's full room count.
          example: 5
        total_sessions:
          type: integer
          example: 40
        total_duration_seconds:
          type: integer
          example: 144000
        total_participant_minutes:
          type: number
          format: float
          example: 5200.0

    SubscriptionUsage:
      type: object
      description: |
        Shape of `usage` depends on `plan_type`. `payg` returns the counters
        PaygBiller maintains against each meter's free allowance and price, plus
        the company's wallet balance. `quota_fixed`/`quota_custom` return a live
        count of rooms/users against the plan's (or subscriber's selected)
        limits — `-1` means unlimited; `sessions.current` is always `null` for
        these two plan types since nothing tracks a "sessions used" counter
        outside payg.
      properties:
        subscription_id:
          type: integer
          example: 3
        plan_type:
          type: string
          enum: [payg, quota_fixed, quota_custom]
          example: payg
        plan_type_label:
          type: string
          example: Pay-as-you-go
        status:
          type: string
          enum: [pending, active, trialing, past_due, expired, cancelled]
          example: active
        status_label:
          type: string
          example: Active
        usage:
          type: object
          description: See the schema description above for the plan-type-dependent shape.
          example:
            sessions_used: 4
            free_sessions: 3
            price_per_session: 5000
            rooms_created: 2
            free_rooms: 1
            price_per_room: 10000
            participant_minutes_used: 30.5
            free_participant_minutes: 100
            price_per_participant_minute: 500
            wallet_balance: 25000.0

paths:
  /pages:
    get:
      tags: [App]
      summary: List all active pages (content excluded for brevity)
      operationId: listPages
      responses:
        '200':
          description: Flat array of active pages without content
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/AppPageListItem'

  /pages/{slug}:
    get:
      tags: [App]
      summary: Get a single page with full content
      operationId: showPage
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
            example: terms
      responses:
        '200':
          description: Page detail with HTML content
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/AppPageDetail'
        '404':
          description: Page not found or inactive

  /app/versions:
    get:
      tags: [App]
      summary: Get current iOS and Android app version requirements
      operationId: appVersions
      responses:
        '200':
          description: App version info
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppVersion'

  /auth/register:
    post:
      tags: [Auth]
      summary: Register a new user
      operationId: authRegister
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, password, password_confirmation]
              properties:
                name:
                  type: string
                  example: John Doe
                email:
                  type: string
                  format: email
                  example: john@example.com
                password:
                  type: string
                  format: password
                  minLength: 8
                  example: password
                password_confirmation:
                  type: string
                  format: password
                  example: password
      responses:
        '201':
          description: User registered successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /auth/login:
    post:
      tags: [Auth]
      summary: Login and receive an access token
      operationId: authLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                  example: test@example.com
                password:
                  type: string
                  format: password
                  example: password
      responses:
        '200':
          description: Login successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        '401':
          description: Invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /auth/logout:
    post:
      tags: [Auth]
      summary: Revoke the current access token
      operationId: authLogout
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Logged out successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '401':
          description: Unauthenticated

  /auth/forgot-password:
    post:
      tags: [Auth]
      summary: Send a password reset link to the given email
      operationId: authForgotPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  example: john@example.com
      responses:
        '200':
          description: Reset link sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Email not found or validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /auth/me:
    get:
      tags: [Auth]
      summary: Return the authenticated user with their roles
      operationId: authMe
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Current user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Unauthenticated

  /auth/profile:
    patch:
      tags: [Auth]
      summary: Update the authenticated user's profile (name, email, avatar)
      operationId: authUpdateProfile
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [name, email]
              properties:
                name:
                  type: string
                  maxLength: 255
                  example: John Doe
                email:
                  type: string
                  format: email
                  maxLength: 255
                  example: john@example.com
                avatar:
                  type: string
                  format: binary
                  description: New avatar image (JPEG/PNG/GIF/WEBP, max 2 MB). Omit to keep existing.
                remove_avatar:
                  type: boolean
                  description: Set to true to delete the current avatar without uploading a replacement.
                  example: false
      responses:
        '200':
          description: Profile updated. If email changed, `email_verified_at` is cleared.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Unauthenticated
        '422':
          description: Validation error (email taken, invalid format, etc.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /auth/password:
    put:
      tags: [Auth]
      summary: Change the authenticated user's password
      operationId: authUpdatePassword
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, password, password_confirmation]
              properties:
                current_password:
                  type: string
                  format: password
                  example: OldPassword1!
                password:
                  type: string
                  format: password
                  example: NewPassword1!
                password_confirmation:
                  type: string
                  format: password
                  example: NewPassword1!
      responses:
        '200':
          description: Password changed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '401':
          description: Unauthenticated
        '422':
          description: Current password is wrong or new password fails validation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /companies:
    get:
      tags: [Companies]
      summary: List companies (scoped by permission)
      description: |
        Returns companies scoped by the caller's permission level:
        - `companies.view-global` (SUPERADMIN): all companies in the system
        - All other roles, including COMPANY_ADMIN: only companies the caller belongs to
        Each company includes its active subscription and plan (features excluded — see Show Company for features).

        Unlike other endpoints under `/companies`, this one ignores the `X-Company-ID`
        header entirely — it is not validated and does not scope the result. Use this
        endpoint to populate a company switcher; it stays reachable even if a
        previously-selected company is no longer valid (e.g. the caller was removed
        from it), so the client can always recover by picking another one.
      operationId: companiesListMine
      security:
        - bearerAuth: []
      parameters:
        - name: search
          in: query
          required: false
          schema:
            type: string
          description: Case-insensitive keyword filter on company name
          example: Aksa
        - name: is_active
          in: query
          required: false
          schema:
            type: integer
            enum: [0, 1]
          description: Filter by active status (1 = active, 0 = inactive)
          example: 1
      responses:
        '200':
          description: List of the user's companies
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Company'
        '401':
          description: Unauthenticated

  /companies/{company}:
    get:
      tags: [Companies]
      summary: Show a company
      description: |
        Returns company detail including active subscription, plan, and all plan features.
        Access is restricted to members of the company or users with `companies.view-global` (SUPERADMIN).
      operationId: companiesShow
      security:
        - bearerAuth: []
      parameters:
        - name: company
          in: path
          required: true
          schema:
            type: integer
          example: 1
      responses:
        '200':
          description: Company detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompanyDetail'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Company not found
    patch:
      tags: [Companies]
      summary: Update a company
      description: |
        Updates company fields. Only SUPERADMIN has `companies.update` permission.
        Does not handle image uploads — use the web admin for that.
      operationId: companiesUpdate
      security:
        - bearerAuth: []
      parameters:
        - name: company
          in: path
          required: true
          schema:
            type: integer
          example: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 255
                  example: Aksa Umroh
                phone:
                  type: string
                  maxLength: 50
                  nullable: true
                  example: '081234567890'
                address:
                  type: string
                  nullable: true
                  example: Jl. Merdeka No. 1
                description:
                  type: string
                  nullable: true
                  example: Travel umroh terpercaya
                is_active:
                  type: boolean
                  example: true
      responses:
        '200':
          description: Updated company detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompanyDetail'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (requires companies.update — SUPERADMIN only)
        '404':
          description: Company not found
        '422':
          description: Validation error
    delete:
      tags: [Companies]
      summary: Delete a company
      description: |
        Permanently deletes a company and its associated storage image (if any).
        Only SUPERADMIN has `companies.delete` permission.
      operationId: companiesDelete
      security:
        - bearerAuth: []
      parameters:
        - name: company
          in: path
          required: true
          schema:
            type: integer
          example: 1
      responses:
        '200':
          description: Company deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Company deleted successfully.
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (requires companies.delete — SUPERADMIN only)
        '404':
          description: Company not found

  /companies/{company}/members:
    get:
      tags: [Companies]
      summary: List members of a company
      description: |
        Returns all users who belong to the given company.
        Requires the `companies.view-members` permission.
        Access is scoped by membership:
        - `companies.view-global` (SUPERADMIN): any company
        - COMPANY_ADMIN and MUTHOWWIF: only companies the caller belongs to
      operationId: companiesListMembers
      security:
        - bearerAuth: []
      parameters:
        - name: company
          in: path
          required: true
          schema:
            type: integer
          example: 1
      responses:
        '200':
          description: List of company members
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (missing permission or not a member of this company)
        '404':
          description: Company not found

  /companies/{company}/invite-code:
    get:
      tags: [Companies]
      summary: Get or generate an active invite code for a company
      description: |
        Returns the current active non-expired invite code for the company.
        If no valid code exists, a new one is generated (valid for 24 hours).
        Requires the `companies.generate-invite` permission (COMPANY_ADMIN, MUTHOWWIF, SUPERADMIN).
        Access is also scoped by membership:
        - `companies.view-global` (SUPERADMIN): any company
        - COMPANY_ADMIN and MUTHOWWIF: only companies the caller belongs to
      operationId: companiesInviteCode
      security:
        - bearerAuth: []
      parameters:
        - name: company
          in: path
          required: true
          schema:
            type: integer
          example: 1
      responses:
        '200':
          description: Active invite code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompanyInviteCode'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (missing companies.generate-invite permission or not a member of this company)
        '404':
          description: Company not found

  /companies/{company}/usage:
    get:
      tags: [Companies]
      summary: Aggregate usage stats across a company's rooms
      description: |
        Returns all-time totals across the company's rooms: room count, session
        count, total duration, and total participant-minutes.
        Access:
        - `analytics.view-global` (SUPERADMIN): any company, full room count
        - `analytics.view-any` (COMPANY_ADMIN): companies they administer, full room count
        - `analytics.view-assigned` (MUTHOWWIF): only companies where they host at
          least one room — the rollup itself is narrowed to just their own rooms,
          not the company's full room count
      operationId: companiesUsageShow
      security:
        - bearerAuth: []
      parameters:
        - name: company
          in: path
          required: true
          schema:
            type: integer
          example: 1
      responses:
        '200':
          description: Company usage summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompanyUsage'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Company not found

  /companies/join:
    post:
      tags: [Companies]
      summary: Join a company using an invite code
      description: |
        Attaches the authenticated user to the company associated with the given invite code.
        The code must be valid and non-expired. Requires the `companies.join` permission (all roles).

        Returns `409` if the caller is a SuperAdmin — SuperAdmin can never become a company member.
      operationId: companiesJoin
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  example: aB3xZ9mQ
      responses:
        '200':
          description: Successfully joined the company
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (missing companies.join permission)
        '409':
          description: Already a member of this company, or caller is a SuperAdmin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Invalid or expired invite code, or validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /companies/onboard:
    post:
      tags: [Companies]
      summary: Create a company and become a Company Admin (onboarding step 1)
      description: |
        Creates a new company, attaches the authenticated user as a member, and assigns
        them the `COMPANY_ADMIN` role scoped to that company. This is the first
        self-service step before subscribing to a plan.

        Returns `409` if the user already has a company, or if the caller is a
        SuperAdmin — SuperAdmin can never become a company member.
      operationId: companiesOnboard
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, account_type]
              properties:
                name:
                  type: string
                  maxLength: 255
                  example: Aksa Travel Umroh
                account_type:
                  type: string
                  enum: [personal, business]
                  example: business
                employees_range:
                  type: string
                  enum: ['1', '2-5', '6-20', '21-50', '50+']
                  description: Required when `account_type` is `business`.
                  example: '2-5'
                phone:
                  type: string
                  nullable: true
                  example: "+6281234567890"
                address:
                  type: string
                  nullable: true
                  example: Jl. Merdeka No. 1, Jakarta
                description:
                  type: string
                  nullable: true
                  example: Travel umroh terpercaya
      responses:
        '201':
          description: Company created and caller is now Company Admin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Company'
        '401':
          description: Unauthenticated
        '409':
          description: Caller already has a company
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /plans:
    get:
      tags: [Plans]
      summary: List active subscription plans (onboarding step 2)
      description: |
        Returns all active plans with their features, configurable dimensions, and
        cross-dimension rules. Use this to render the plan picker before calling
        `POST /subscriptions`.

        - `features`: fixed limits for `quota_fixed` / `pay_as_you_go` plans (e.g. `max_rooms: 5`; `-1` means unlimited).
        - `dimensions`: configurable sliders for `quota_custom` plans. Each dimension has `min_value`, `max_value`, `step`, and `price_per_unit`.
        - `dimension_rules`: cross-dimension constraints (e.g. "if sessions > 50, rooms must be ≥ 10"). Mirror these checks client-side.
      operationId: plansList
      security:
        - bearerAuth: []
      responses:
        '200':
          description: List of active plans
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 1
                        name:
                          type: string
                          example: Basic
                        plan_type:
                          type: string
                          enum: [pay_as_you_go, quota_fixed, quota_custom]
                          example: quota_fixed
                        plan_type_label:
                          type: string
                          example: Quota Fixed
                        price:
                          type: string
                          description: Decimal string (e.g. "99000.00")
                          example: "99000.00"
                        billing_cycle:
                          type: string
                          enum: [monthly, yearly]
                          example: monthly
                        billing_cycle_label:
                          type: string
                          example: Monthly
                        features:
                          type: array
                          items:
                            type: object
                            properties:
                              feature_key:
                                type: string
                                example: max_rooms
                              feature_value:
                                type: integer
                                description: -1 means unlimited
                                example: 5
                        dimensions:
                          type: array
                          description: Non-empty for quota_custom plans only
                          items:
                            type: object
                            properties:
                              key:
                                type: string
                                example: max_rooms
                              label:
                                type: string
                                example: Max Rooms
                              unit:
                                type: string
                                example: rooms
                              min_value:
                                type: integer
                                example: 1
                              max_value:
                                type: integer
                                nullable: true
                                example: 50
                              step:
                                type: integer
                                example: 1
                              price_per_unit:
                                type: number
                                example: 10000
                              is_required:
                                type: boolean
                                example: true
                        dimension_rules:
                          type: array
                          description: Cross-dimension constraints for quota_custom plans
                          items:
                            type: object
                            properties:
                              if_dimension_key:
                                type: string
                              if_operator:
                                type: string
                              if_value:
                                type: integer
                              then_dimension_key:
                                type: string
                              then_operator:
                                type: string
                              then_value:
                                type: integer
                              message:
                                type: string
                                nullable: true
        '401':
          description: Unauthenticated

  /subscriptions:
    get:
      tags: [Subscriptions]
      summary: List subscriptions for the current company
      description: |
        Returns all subscriptions for the company resolved from the `X-Company-ID`
        header, or the caller's first company if the header is omitted. Results
        are ordered latest first. Returns an empty list when the caller belongs
        to no company.
      operationId: subscriptionsList
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/CompanyIdHeader'
      responses:
        '200':
          description: List of subscriptions for the company
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Subscription'
        '401':
          description: Unauthenticated

    post:
      tags: [Subscriptions]
      summary: Subscribe the caller's company to a plan (onboarding step 3)
      description: |
        Creates a subscription for the authenticated user's first company, starts a
        14-day trial, and assigns the caller the global `MUTHOWWIF` role so they can
        create and broadcast rooms.

        For `quota_custom` plans, pass the `quotas` array with a value for each required
        dimension. The server validates bounds and cross-dimension rules; mirror the
        client-side checks shown in `GET /plans` dimension_rules.

        Returns `422` (not `403`) with a plain `message` if the caller has no company yet
        — call `POST /companies/onboard` first. Returns `403` if the caller belongs to the
        company but is not one of its admins. Returns `409` if the company already has a
        subscription that hasn't reached a terminal state (pending, active, trialing, or
        past due) — cancel it via `DELETE /subscriptions/{subscription}` first.
      operationId: subscriptionsCreate
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_id]
              properties:
                plan_id:
                  type: integer
                  description: ID of an active plan from `GET /plans`
                  example: 1
                quotas:
                  type: array
                  description: Required for quota_custom plans. One entry per dimension.
                  items:
                    type: object
                    required: [key, value]
                    properties:
                      key:
                        type: string
                        example: max_rooms
                      value:
                        type: integer
                        example: 10
      responses:
        '201':
          description: Subscription created; caller is now Muthowwif
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    example: 3
                  company_id:
                    type: integer
                    example: 1
                  plan:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 1
                      name:
                        type: string
                        example: Basic
                      plan_type:
                        type: string
                        example: quota_fixed
                      billing_cycle:
                        type: string
                        example: monthly
                  status:
                    type: string
                    example: trialing
                  status_label:
                    type: string
                    example: Trialing
                  starts_at:
                    type: string
                    format: date-time
                  trial_ends_at:
                    type: string
                    format: date-time
                  ends_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (caller is a company member but not an admin)
        '409':
          description: Company already has a subscription that hasn't reached a terminal state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: No company yet, inactive plan, or quota validation failure
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/MessageResponse'
                  - $ref: '#/components/schemas/ValidationError'

  /subscriptions/{subscription}:
    get:
      tags: [Subscriptions]
      summary: Show a single subscription
      description: |
        Returns the subscription with its plan details.
        Access is restricted to members of the subscription's company
        or users with `rooms.view-global` (SUPERADMIN).
      operationId: subscriptionsShow
      security:
        - bearerAuth: []
      parameters:
        - name: subscription
          in: path
          required: true
          schema:
            type: integer
          example: 3
      responses:
        '200':
          description: Subscription detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (subscription belongs to a different company)
        '404':
          description: Subscription not found

    delete:
      tags: [Subscriptions]
      summary: Cancel a subscription
      description: |
        Sets the subscription status to `cancelled` and records `ends_at` as now.
        Access is restricted to admins of the subscription's company
        or users with `rooms.view-global` (SUPERADMIN).
        Returns `422` if the subscription is already cancelled.
      operationId: subscriptionsCancel
      security:
        - bearerAuth: []
      parameters:
        - name: subscription
          in: path
          required: true
          schema:
            type: integer
          example: 3
      responses:
        '200':
          description: Subscription cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '401':
          description: Unauthenticated
        '403':
          description: |
            Forbidden — the subscription belongs to a different company, or the
            caller is a member of its company but not one of its admins.
        '404':
          description: Subscription not found
        '422':
          description: Subscription is already cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /subscriptions/{subscription}/usage:
    get:
      tags: [Subscriptions]
      summary: Show usage vs. quota for a subscription
      description: |
        Returns the subscription's plan type and a plan-type-dependent `usage`
        object — see the `SubscriptionUsage` schema for the exact shape per type.
        Admin-only: not extended to MUTHOWWIF, unlike the room/company usage
        endpoints — this includes billing-sensitive data (wallet balance, quota
        consumption) that a room host doesn't need visibility into.
        Access:
        - `analytics.view-global` (SUPERADMIN): any subscription
        - `analytics.view-any` (COMPANY_ADMIN): only subscriptions of a company they administer
      operationId: subscriptionsUsageShow
      security:
        - bearerAuth: []
      parameters:
        - name: subscription
          in: path
          required: true
          schema:
            type: integer
          example: 3
      responses:
        '200':
          description: Subscription usage
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriptionUsage'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Subscription not found

  /wallet:
    get:
      tags: [Wallet]
      summary: Show the current company's pay-as-you-go wallet
      description: |
        Returns the balance, whether the caller can submit a top-up request, and
        the 10 most recent top-up requests (any status) for the company resolved
        from the `X-Company-ID` header, or the caller's first company if the
        header is omitted.

        `has_wallet` is `false` (with no other fields) when the caller belongs to
        no company, or the resolved company has no active pay-as-you-go
        subscription — a wallet only exists for pay-as-you-go companies.
      operationId: walletShow
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/CompanyIdHeader'
      responses:
        '200':
          description: Wallet state for the current company
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      has_wallet:
                        type: boolean
                        example: false
                  - type: object
                    properties:
                      has_wallet:
                        type: boolean
                        example: true
                      balance:
                        type: number
                        example: 150000
                      can_request_top_up:
                        type: boolean
                        description: True when the caller is an admin of this company.
                        example: true
                      requests:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 12
                            amount:
                              type: number
                              example: 50000
                            status:
                              type: string
                              example: pending
                            status_label:
                              type: string
                              example: Pending
                            description:
                              type: string
                              nullable: true
                              example: Bank transfer ref #123
                            rejected_reason:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
        '401':
          description: Unauthenticated

  /wallet/topup-request:
    post:
      tags: [Wallet]
      summary: Submit a wallet top-up request
      description: |
        Creates a pending top-up request for the current company's wallet after
        the caller has made an off-platform payment (e.g. bank transfer). A
        SuperAdmin reviews and approves or rejects it; the balance is only
        credited on approval. Restricted to admins of the resolved company.
      operationId: walletTopUpRequestCreate
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/CompanyIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: number
                  minimum: 1
                  example: 50000
                description:
                  type: string
                  nullable: true
                  example: Transferred via BCA, ref #12345
      responses:
        '201':
          description: Top-up request submitted, pending review
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    example: 12
                  amount:
                    type: number
                    example: 50000
                  status:
                    type: string
                    example: pending
                  status_label:
                    type: string
                    example: Pending
                  description:
                    type: string
                    nullable: true
                  rejected_reason:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (caller is not an admin of the resolved company)
        '422':
          description: No company yet, or validation failure
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/MessageResponse'
                  - $ref: '#/components/schemas/ValidationError'

  /notifications:
    get:
      tags: [Notifications]
      summary: List the authenticated user's notifications
      operationId: notificationsList
      security:
        - bearerAuth: []
      parameters:
        - name: per_page
          in: query
          required: false
          schema:
            type: integer
            default: 20
            enum: [10, 20, 50, 100]
          example: 20
      responses:
        '200':
          description: Paginated list of the caller's own notifications, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Notification'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
        '401':
          description: Unauthenticated

  /notifications/read-all:
    post:
      tags: [Notifications]
      summary: Mark all of the authenticated user's notifications as read
      operationId: notificationsMarkAllRead
      security:
        - bearerAuth: []
      responses:
        '200':
          description: All notifications marked as read
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '401':
          description: Unauthenticated

  /notifications/{notificationId}/read:
    post:
      tags: [Notifications]
      summary: Mark a single notification as read
      description: |
        Only matches notifications belonging to the caller — a notification
        that exists but belongs to another user returns `404`, same as one
        that doesn't exist at all.
      operationId: notificationsMarkRead
      security:
        - bearerAuth: []
      parameters:
        - name: notificationId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: 9d1f2e3a-4b5c-4d6e-8f7a-1b2c3d4e5f6a
      responses:
        '200':
          description: Updated notification
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Notification'
        '401':
          description: Unauthenticated
        '404':
          description: Notification not found, or does not belong to the caller

  /rooms:
    get:
      tags: [Rooms]
      summary: List rooms (scoped by permission)
      description: |
        Returns rooms scoped by the caller's permission level:
        - `rooms.view-managed` (SUPERADMIN, COMPANY_ADMIN): all rooms
        - `rooms.view-assigned` (MUTHOWWIF): only rooms where the caller is a muthowwif
        - `rooms.view` (USER): only rooms the caller has joined as a participant

        The `X-Company-ID` header can be used instead of (or in addition to) the
        `company_id` query parameter to filter by company. When both are present,
        the query parameter takes precedence.
      operationId: roomsList
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/CompanyIdHeader'
        - name: company_id
          in: query
          required: false
          schema:
            type: integer
          description: Filter rooms by company ID. Overrides `X-Company-ID` header when both are present.
          example: 1
        - name: search
          in: query
          required: false
          schema:
            type: string
          description: Case-insensitive keyword filter on room name
          example: Umroh
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [active, scheduled, closed]
          description: Filter rooms by status
          example: active
        - name: archived
          in: query
          required: false
          schema:
            type: string
            enum: ['false', 'true', 'all']
            default: 'false'
          description: |
            Filter rooms by archive state.
            - `false` (default): exclude archived rooms
            - `true`: only archived rooms
            - `all`: include both archived and non-archived
        - name: created_from
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Return rooms created on or after this date (YYYY-MM-DD)
          example: '2026-01-01'
        - name: created_to
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Return rooms created on or before this date (YYYY-MM-DD)
          example: '2026-12-31'
        - name: archived_from
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Return rooms archived on or after this date (YYYY-MM-DD)
          example: '2026-01-01'
        - name: archived_to
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Return rooms archived on or before this date (YYYY-MM-DD)
          example: '2026-12-31'
        - name: sort_by
          in: query
          required: false
          schema:
            type: string
            enum: [name, status, created_at, updated_at, archived_at]
            default: created_at
          description: Column to sort by (defaults to created_at; invalid values fall back to created_at)
          example: created_at
        - name: sort_order
          in: query
          required: false
          schema:
            type: string
            enum: [asc, desc]
            default: desc
          description: Sort direction
          example: desc
      responses:
        '200':
          description: Scoped list of rooms
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Room'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden

    post:
      tags: [Rooms]
      summary: Create a new room
      description: |
        Creates a room under the given company. Requires `rooms.create` permission (MUTHOWWIF, COMPANY_ADMIN, SUPERADMIN).

        `company_id` in the request body can be omitted when the `X-Company-ID` header is set — the
        header value is used as the company. When both are provided the body field takes precedence.

        No LiveKit session is provisioned here — `livekit_room` in the response is always `null`.
        Room creation and starting a broadcast are separate steps; call Join Room to actually start
        (or join) a session. On a pay-as-you-go company, this charges the plan's `price_per_room`
        meter once its free-room allowance is used up.
      operationId: roomsCreate
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/CompanyIdHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Room display name, max 100 chars
                  example: Haji 2025 Group A
                company_id:
                  type: integer
                  description: Required unless `X-Company-ID` header is set.
                  example: 1
                description:
                  type: string
                  nullable: true
                  example: null
                status:
                  type: string
                  enum: [active, scheduled, closed]
                  default: active
      responses:
        '201':
          description: Room created (MUTHOWWIF, SUPERADMIN only)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Room'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '402':
          description: Insufficient wallet balance (pay-as-you-go company only)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '409':
          description: Room already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /rooms/{short_code}:
    get:
      tags: [Rooms]
      summary: Get a single room (scoped by permission)
      description: |
        Access is scoped by the caller's permission level:
        - `rooms.view-managed` (SUPERADMIN, COMPANY_ADMIN): any room
        - `rooms.view-assigned` (MUTHOWWIF): only rooms where the caller is a muthowwif
        - `rooms.view` (USER): only rooms the caller has joined as a participant
      operationId: roomsShow
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: Room found and accessible
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Room'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (room exists but caller cannot access it)
        '404':
          description: Room not found

    put:
      tags: [Rooms]
      summary: Update a room's name, description, or status
      description: |
        Requires `rooms.update` permission (COMPANY_ADMIN, SUPERADMIN).
        Company-ownership is enforced: non-superadmins can only update rooms
        that belong to one of their companies.
        `company_id` cannot be changed via this endpoint.
        Omitting `description` preserves the current value; passing `null` clears it.
      operationId: roomsUpdate
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 100
                  example: Haji 2025 Group B
                description:
                  type: string
                  nullable: true
                  example: Updated description
                status:
                  type: string
                  enum: [active, scheduled, closed]
                  nullable: true
                  example: scheduled
      responses:
        '200':
          description: Room updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Room'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (missing rooms.update permission or wrong company)
        '404':
          description: Room not found
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /rooms/{short_code}/participants:
    get:
      tags: [Rooms]
      summary: List participants in a room
      description: |
        Returns all members of the room (both muthowwifs and participants).
        Access is scoped by the caller's permission level (same rules as Show Room).
        Field visibility depends on permission:
        - `rooms.view-managed` (SUPERADMIN, COMPANY_ADMIN): full data (`RoomParticipantFull`)
        - Others: limited data (`RoomParticipantLimited` — id, name, avatar, role)

        Also reachable by a guest using their Guest Join token, scoped to the
        room they joined (see `guestBearerAuth`).
      operationId: roomsParticipantsList
      security:
        - bearerAuth: []
        - guestBearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: List of room members
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/RoomParticipantFull'
                        - $ref: '#/components/schemas/RoomParticipantLimited'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Room not found

  /rooms/{short_code}/join:
    post:
      tags: [Rooms]
      summary: Join a room and receive a LiveKit participant token
      description: |
        Joins an existing LiveKit session, or — if the caller can publish and
        no session is currently live — starts one. Starting a new session on
        a pay-as-you-go company charges the plan's `price_per_session` meter
        once its free-session allowance is used up; joining an already-live
        session never charges. A caller who cannot publish (`rooms.publish`)
        gets `422` if no session is currently live, since only a publisher
        can start one.
      operationId: roomsJoin
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: Token issued (MUTHOWWIF, SUPERADMIN only)
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: LiveKit participant JWT
                    example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
                  role:
                    type: string
                    enum: [host, listener]
                    example: host
                  room:
                    $ref: '#/components/schemas/Room'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '402':
          description: Insufficient wallet balance to start a new session (pay-as-you-go company only)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '404':
          description: Room not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Room is archived, or not currently live and the caller cannot start a session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '503':
          description: Streaming service unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /rooms/{short_code}/guest-preview:
    get:
      tags: [Rooms]
      summary: Preview a room as an unauthenticated guest, before joining
      description: |
        Public endpoint — no authentication required, no side effects (no
        guest session is recorded, no LiveKit token is issued). Lets a guest
        client see whether a room exists and its basic public details before
        committing to Join.
      operationId: roomsGuestPreview
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: Room preview
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/GuestRoomPreview'
        '404':
          description: Room not found or archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '429':
          description: Rate limit exceeded

  /rooms/{short_code}/guest-join:
    post:
      tags: [Rooms]
      summary: Join a room as an unauthenticated guest
      description: Public endpoint — no authentication required. Returns a LiveKit listener token. Guest session is recorded for tracking purposes.
      operationId: roomsGuestJoin
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 100
                  example: Ahmad Fauzi
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: LiveKit participant JWT
                    example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
                  wsUrl:
                    type: string
                    description: LiveKit WebSocket URL to connect to with the token
                    example: wss://livekit.example.com
                  identity:
                    type: string
                    description: Guest identity used in the LiveKit room
                    example: guest_550e8400-e29b-41d4-a716-446655440000
                  name:
                    type: string
                    example: Ahmad Fauzi
                  role:
                    type: string
                    enum: [listener]
                    example: listener
                  room:
                    $ref: '#/components/schemas/Room'
        '404':
          description: Room not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Room is archived, not currently active, at full capacity, or validation failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /rooms/{short_code}/token/refresh:
    post:
      tags: [Rooms]
      summary: Refresh the caller's LiveKit token before it expires
      description: |
        Mints a fresh LiveKit token for the caller's existing identity and
        role — does not create or bill a new session, so the room must
        already be live. Reachable by both an authenticated user (subject to
        the same `rooms.join` rules as Join) and a guest using their Guest
        Join token (see `guestBearerAuth`). The identity in the new token is
        always the same as the one originally issued, so LiveKit treats this
        as a continuation, not a new participant joining.
      operationId: roomsTokenRefresh
      security:
        - bearerAuth: []
        - guestBearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: Token refreshed
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: LiveKit participant JWT
                    example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
                  role:
                    type: string
                    enum: [host, listener]
                    example: listener
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Room not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          description: Room is archived or not currently active
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '503':
          description: Streaming service unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /rooms/{short_code}/archive:
    post:
      tags: [Rooms]
      summary: Archive a room
      operationId: roomsArchive
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: Room archived successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Room'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (requires rooms.update permission)
        '404':
          description: Room not found
        '422':
          description: Room is already archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /rooms/{short_code}/unarchive:
    post:
      tags: [Rooms]
      summary: Unarchive a room
      operationId: roomsUnarchive
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: Room unarchived successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Room'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (requires rooms.update permission)
        '404':
          description: Room not found
        '422':
          description: Room is not archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'

  /rooms/{short_code}/sessions:
    get:
      tags: [Rooms]
      summary: List past broadcast sessions for a room
      description: |
        Returns paginated broadcast sessions for the room, ordered latest first.
        Access follows the same 4-tier room visibility check as Show Room:
        - `rooms.view-managed` (SUPERADMIN, COMPANY_ADMIN): any room
        - `rooms.view-assigned` (MUTHOWWIF): only rooms where the caller is a muthowwif
        - `rooms.view` (USER): only rooms the caller joined as a participant
        Participants are **not** included in list results — use Show Session for those.

        Also reachable by a guest using their Guest Join token, scoped to the
        room they joined (see `guestBearerAuth`).
      operationId: roomsSessionsList
      security:
        - bearerAuth: []
        - guestBearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
        - name: per_page
          in: query
          required: false
          schema:
            type: integer
            default: 15
            maximum: 100
          description: Number of sessions per page
          example: 15
      responses:
        '200':
          description: Paginated list of sessions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/RoomSession'
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                      last_page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Room not found

  /rooms/{short_code}/sessions/{session}:
    get:
      tags: [Rooms]
      summary: Show a single broadcast session with all participants
      description: |
        Returns session detail including the full list of participants.
        Returns `404` if the session does not belong to the given room.
        Access follows the same 4-tier room visibility check as Show Room.
      operationId: roomsSessionsShow
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
        - name: session
          in: path
          required: true
          schema:
            type: integer
          example: 7
      responses:
        '200':
          description: Session detail with participants
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomSession'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Room not found or session does not belong to this room

  /rooms/{short_code}/usage:
    get:
      tags: [Rooms]
      summary: Aggregate usage stats for a single room
      description: |
        Returns all-time totals for a room: session count, total duration, total
        participant-minutes, and average session duration.
        Access:
        - `analytics.view-global` (SUPERADMIN): any room
        - `analytics.view-any` (COMPANY_ADMIN): rooms in a company they administer
        - `analytics.view-assigned` (MUTHOWWIF): only rooms they personally host
      operationId: roomsUsageShow
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
      responses:
        '200':
          description: Room usage summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomUsage'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Room not found

  /rooms/{short_code}/muthowwifs:
    get:
      tags: [Rooms]
      summary: List muthowwifs assigned to a room
      description: |
        Returns a paginated list of muthowwifs (hosts) for the given room.
        Access follows the same 4-tier room visibility check as Show Room.
      operationId: roomsMuthowwifsList
      security:
        - bearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
        - name: per_page
          in: query
          required: false
          schema:
            type: integer
            default: 15
            maximum: 100
          example: 15
      responses:
        '200':
          description: Paginated muthowwif list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/RoomParticipantFull'
                        - $ref: '#/components/schemas/RoomParticipantLimited'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Room not found

  /rooms/{short_code}/guests:
    get:
      tags: [Rooms]
      summary: List guests who joined a room
      description: |
        Returns a paginated list of anonymous guests for the given room.
        IP address and user agent are excluded from the response for privacy.
        Access follows the same 4-tier room visibility check as Show Room.

        Also reachable by a guest using their Guest Join token, scoped to the
        room they joined (see `guestBearerAuth`).
      operationId: roomsGuestsList
      security:
        - bearerAuth: []
        - guestBearerAuth: []
      parameters:
        - name: short_code
          in: path
          required: true
          schema:
            type: string
          example: A1B2C3D4E5
        - name: per_page
          in: query
          required: false
          schema:
            type: integer
            default: 15
            maximum: 100
          example: 15
      responses:
        '200':
          description: Paginated guest list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/RoomGuest'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: Room not found

  /tokens:
    get:
      tags: [Tokens]
      summary: List the authenticated user's active sessions
      operationId: tokensList
      security:
        - bearerAuth: []
      responses:
        '200':
          description: List of sessions belonging to the authenticated user
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PersonalAccessToken'
        '401':
          description: Unauthenticated

  /tokens/{tokenId}:
    delete:
      tags: [Tokens]
      summary: Revoke a session token
      operationId: tokensRevoke
      security:
        - bearerAuth: []
      parameters:
        - name: tokenId
          in: path
          required: true
          schema:
            type: integer
          example: 42
      responses:
        '204':
          description: Token revoked
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (not owner and lacks sessions.revoke-any)
        '404':
          description: Token not found

  /tokens/{tokenId}/firebase-token:
    patch:
      tags: [Tokens]
      summary: Update the Firebase device token for a session
      operationId: tokensUpdateFirebase
      security:
        - bearerAuth: []
      parameters:
        - name: tokenId
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [firebase_device_token]
              properties:
                firebase_device_token:
                  type: string
                  example: fCm_device_token_abc123
      responses:
        '200':
          description: Updated session
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PersonalAccessToken'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (not owner)
        '404':
          description: Token not found
        '422':
          description: Validation error

  /users:
    get:
      tags: [Users]
      summary: List managed users
      description: |
        Returns users scoped by the caller's permission level, requiring `users.view-managed`:
        - `users.view-global` (SUPERADMIN): every user in the system
        - All other roles, including COMPANY_ADMIN: only users belonging to a company the caller administers
      operationId: usersList
      security:
        - bearerAuth: []
      parameters:
        - name: search
          in: query
          required: false
          schema:
            type: string
          description: Case-insensitive keyword filter on name or email
          example: Jane
        - name: role
          in: query
          required: false
          schema:
            type: string
          description: Filter by role name
          example: MUTHOWWIF
        - name: company_id
          in: query
          required: false
          schema:
            type: integer
          description: Filter by company membership
          example: 1
        - name: per_page
          in: query
          required: false
          schema:
            type: integer
            enum: [10, 20, 50, 100]
            default: 20
      responses:
        '200':
          description: Paginated list of users
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (requires users.view-managed)
    post:
      tags: [Users]
      summary: Create a user
      description: |
        Creates a managed user with the given roles and company memberships.
        Requires `users.create`. The `companies` and `roles.*` values are
        restricted to what the caller may assign — company IDs the caller
        administers (or any company with `users.view-global`), and any global
        role except SUPERADMIN.
      operationId: usersCreate
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [name, email, password]
              properties:
                name:
                  type: string
                  maxLength: 255
                  example: Jane Doe
                email:
                  type: string
                  format: email
                  example: jane@example.com
                password:
                  type: string
                  example: super-secret
                avatar:
                  type: string
                  format: binary
                roles:
                  type: array
                  items:
                    type: string
                  example: [MUTHOWWIF]
                companies:
                  type: array
                  items:
                    type: integer
                  example: [1]
      responses:
        '201':
          description: Created user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (requires users.create)
        '422':
          description: Validation error

  /users/roles:
    get:
      tags: [Users]
      summary: List assignable roles
      description: |
        Returns the role names that may be assigned when creating or updating
        a managed user (via the `roles` field on Create/Update User) — every
        global role except SUPERADMIN, which is never self-assignable through
        user management. Requires `users.create` or `users.update`.
      operationId: usersListAssignableRoles
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Assignable role names
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: string
                    example: [COMPANY_ADMIN, MUTHOWWIF, USER]
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden (requires users.create or users.update)

  /users/{user}:
    get:
      tags: [Users]
      summary: Show a user
      description: |
        Access is restricted to users.view-managed and scoped the same way as
        the list endpoint above (company-scoped unless users.view-global).
      operationId: usersShow
      security:
        - bearerAuth: []
      parameters:
        - name: user
          in: path
          required: true
          schema:
            type: integer
          example: 1
      responses:
        '200':
          description: User detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: User not found
    put:
      tags: [Users]
      summary: Update a user
      description: |
        Updates a managed user's fields, roles, and company memberships.
        Requires `users.update` and the same company-scoping as Show. Omit
        `password` to leave it unchanged. Set `remove_avatar` to `true` to
        clear the stored avatar without uploading a new one. `companies` is
        silently ignored if the target user is a SuperAdmin — SuperAdmin can
        never become a company member.
      operationId: usersUpdate
      security:
        - bearerAuth: []
      parameters:
        - name: user
          in: path
          required: true
          schema:
            type: integer
          example: 1
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [name, email]
              properties:
                name:
                  type: string
                  maxLength: 255
                  example: Jane Doe
                email:
                  type: string
                  format: email
                  example: jane@example.com
                password:
                  type: string
                  nullable: true
                  example: brand-new-password
                avatar:
                  type: string
                  format: binary
                remove_avatar:
                  type: boolean
                roles:
                  type: array
                  items:
                    type: string
                  example: [MUTHOWWIF]
                companies:
                  type: array
                  items:
                    type: integer
                  example: [1]
      responses:
        '200':
          description: Updated user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: User not found
        '422':
          description: Validation error
    delete:
      tags: [Users]
      summary: Delete a user
      description: |
        Permanently deletes a user and their stored avatar (if any). Requires
        `users.delete` and the same company-scoping as Show.
      operationId: usersDelete
      security:
        - bearerAuth: []
      parameters:
        - name: user
          in: path
          required: true
          schema:
            type: integer
          example: 1
      responses:
        '200':
          description: User deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: User deleted successfully.
        '401':
          description: Unauthenticated
        '403':
          description: Forbidden
        '404':
          description: User not found

  /webhooks/livekit:
    post:
      tags: [Webhooks]
      summary: Receive LiveKit webhook events
      description: |
        Public endpoint called by LiveKit. Every request is signed with a JWT
        using the LiveKit API secret. The endpoint verifies the signature via
        `WebhookReceiver::receive()` and returns 400 on failure. On success it
        dispatches a Laravel event and immediately returns 200. Real work
        (writing to `room_sessions` and `room_session_participants`) happens in
        queued listeners.

        LiveKit retries delivery for up to ~1 hour on non-2xx responses.
        Configure the webhook URL in LiveKit Cloud → Settings → Webhooks or in
        `livekit.yaml` (self-hosted). See `README.md` for setup instructions.
      operationId: webhooksLiveKit
      security: []
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: LiveKit-signed JWT (`Bearer <token>`)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw LiveKit WebhookEvent JSON body (protobuf JSON encoding)
              properties:
                event:
                  type: string
                  enum: [room_started, room_finished, participant_joined, participant_left, track_published, track_unpublished, egress_started, egress_updated, egress_ended]
                  example: room_started
                room:
                  type: object
                  properties:
                    name:
                      type: string
                      description: Room short code (our identifier)
                    sid:
                      type: string
                      description: LiveKit session SID — unique per broadcast, resets on restart
                    numParticipants:
                      type: integer
                    creationTimeMs:
                      type: integer
                      description: Unix milliseconds when this session started
                participant:
                  type: object
                  nullable: true
                  properties:
                    identity:
                      type: string
                      description: User ID or `guest_<uuid>`
                    name:
                      type: string
                    isPublisher:
                      type: boolean
                    joinedAtMs:
                      type: integer
                    disconnectReason:
                      type: string
                      nullable: true
                createdAt:
                  type: integer
                  description: Unix timestamp of the event
      responses:
        '200':
          description: Event received and queued for processing
        '400':
          description: Invalid signature or malformed payload
