components:
    responses:
        AttestationFailed:
            content:
                application/json:
                    schema:
                        $ref: '#/components/schemas/GateError'
            description: |
                `client_attestation_failed` — an `X-Client-Attestation` header was
                present but did not verify, was replayed, or was signed by an untrusted
                key. Returned whether or not the client gate is enforced.
        GateUnavailable:
            content:
                application/json:
                    schema:
                        $ref: '#/components/schemas/GateError'
            description: |
                The registry or billing lookup failed. The gate fails closed: access is
                refused rather than granted on unverified state.
        PaymentRequired:
            content:
                application/json:
                    schema:
                        $ref: '#/components/schemas/GateError'
            description: |
                The room's owner has no entitlement covering this call. The body
                carries the catalogue so a client can render the offer inline.
        RateLimited:
            description: Per-IP rate limit exceeded.
    schemas:
        GateError:
            properties:
                error:
                    description: Human-readable explanation.
                    type: string
                plans:
                    description: Present on a 402, so the client can render the offer.
                    items:
                        $ref: '#/components/schemas/Plan'
                    type: array
                reason:
                    description: Machine-readable code.
                    enum:
                        - subscription_required
                        - subscription_expired
                        - free_quota_exceeded
                        - quota_exceeded
                        - room_not_registered
                        - client_attestation_failed
                        - could_not_verify_room
                        - could_not_verify_subscription
                    type: string
                subscribe_api:
                    description: Where to POST to buy. Present on a 402.
                    type: string
                subscribe_url:
                    description: Human-facing page, when configured.
                    format: uri
                    type: string
            type: object
        Invoice:
            properties:
                amount_sats:
                    format: int64
                    type: integer
                bolt11:
                    type: string
                expires_at:
                    format: int64
                    type: integer
                intent_id:
                    format: uuid
                    type: string
                payment_hash:
                    type: string
                plan:
                    type: string
                status:
                    type: string
            type: object
        LobbyDecision:
            properties:
                pubkey:
                    $ref: '#/components/schemas/Pubkey'
                roomName:
                    type: string
            required:
                - roomName
                - pubkey
            type: object
        NostrEvent:
            properties:
                content:
                    type: string
                created_at:
                    format: int64
                    type: integer
                id:
                    type: string
                kind:
                    type: integer
                pubkey:
                    $ref: '#/components/schemas/Pubkey'
                sig:
                    type: string
                tags:
                    items:
                        items:
                            type: string
                        type: array
                    type: array
            required:
                - id
                - pubkey
                - created_at
                - kind
                - tags
                - content
                - sig
            type: object
        OwnedRoom:
            description: |
                One room owned by a pubkey. Identity, name and privacy come from the
                registry; description and picture come from the room's kind-30312.
            properties:
                audience_mode:
                    type: boolean
                created_via:
                    enum:
                        - dashboard
                        - api
                    type: string
                identifier:
                    description: The room's kind-30312 `d` tag.
                    type: string
                lobby_enabled:
                    type: boolean
                locked:
                    type: boolean
                mute_on_join:
                    type: boolean
                pubkey:
                    $ref: '#/components/schemas/Pubkey'
                room_description:
                    type: string
                room_id:
                    format: uuid
                    type: string
                room_name:
                    type: string
                room_picture_url:
                    type: string
                status:
                    enum:
                        - open
                        - private
                    type: string
                updated_at:
                    format: date-time
                    type: string
            type: object
        ParticipantAction:
            properties:
                participantIdentity:
                    type: string
                roomName:
                    type: string
            required:
                - roomName
                - participantIdentity
            type: object
        PaymentStatus:
            properties:
                expires_at:
                    format: int64
                    type: integer
                intent_id:
                    format: uuid
                    type: string
                plan:
                    type: string
                settled_msat:
                    format: int64
                    type: integer
                status:
                    enum:
                        - pending
                        - settled
                        - expired
                        - failed
                    type: string
                subscription:
                    allOf:
                        - $ref: '#/components/schemas/Subscription'
                    description: Present once the payment has settled and the entitlement is live.
            type: object
        Plan:
            properties:
                days:
                    type: integer
                id:
                    enum:
                        - standard_1y
                        - standard_2y
                        - bulk10_1y
                        - bulk10_2y
                    type: string
                price_sats:
                    format: int64
                    type: integer
                room_quota:
                    type: integer
            type: object
        PollState:
            properties:
                counts:
                    items:
                        type: integer
                    type: array
                poll:
                    properties:
                        allow_multiple:
                            type: boolean
                        closed_at:
                            format: date-time
                            type:
                                - string
                                - "null"
                        created_at:
                            format: date-time
                            type: string
                        creator_pubkey:
                            $ref: '#/components/schemas/Pubkey'
                        id:
                            format: uuid
                            type: string
                        options:
                            items:
                                properties:
                                    idx:
                                        type: integer
                                    label:
                                        type: string
                                type: object
                            type: array
                        question:
                            type: string
                        room_name:
                            type: string
                        spotlight:
                            type: boolean
                        status:
                            enum:
                                - open
                                - closed
                            type: string
                    type: object
                selections:
                    description: The calling pubkey's own choices.
                    items:
                        type: integer
                    type: array
                voters:
                    additionalProperties:
                        items:
                            $ref: '#/components/schemas/Pubkey'
                        type: array
                    description: Present only when the poll is not anonymous.
                    type: object
            type: object
        Pubkey:
            description: Nostr public key, lowercase hex.
            pattern: ^[0-9a-f]{64}$
            type: string
        Recording:
            properties:
                duration_seconds:
                    type: integer
                expires_at:
                    format: date-time
                    type: string
                recording_id:
                    format: uuid
                    type: string
                room_name:
                    type: string
                size_bytes:
                    format: int64
                    type: integer
                started_at:
                    format: date-time
                    type: string
                status:
                    enum:
                        - active
                        - stopping
                        - stopped
                        - failed
                        - expired
                    type: string
            type: object
        RecordingStart:
            properties:
                egress_id:
                    type: string
                expires_at:
                    format: date-time
                    type: string
                object_key:
                    type: string
                recording_id:
                    format: uuid
                    type: string
                room_name:
                    type: string
                started_at:
                    format: date-time
                    type: string
                status:
                    type: string
            type: object
        RecordingStatus:
            properties:
                egress_id:
                    type: string
                recording:
                    type: boolean
                reserved_at:
                    format: int64
                    type: integer
                room_name:
                    type: string
                started_at:
                    format: int64
                    type: integer
                status:
                    type: string
            type: object
        RecordingStop:
            properties:
                egress_id:
                    type: string
                recording_id:
                    format: uuid
                    type: string
                room_name:
                    type: string
                status:
                    type: string
                stop_requested_at:
                    format: date-time
                    type: string
            type: object
        RegisteredRoom:
            properties:
                broadcast_pending:
                    type: boolean
                created_via:
                    enum:
                        - dashboard
                        - api
                        - backfill
                    type: string
                is_private:
                    type: boolean
                owner_pubkey:
                    $ref: '#/components/schemas/Pubkey'
                room_id:
                    description: Immutable; survives renames.
                    format: uuid
                    type: string
                room_name:
                    type: string
            type: object
        RoomInfo:
            additionalProperties: true
            description: Registry and Nostr metadata for one room.
            properties:
                is_private:
                    type: boolean
                owner_pubkey:
                    $ref: '#/components/schemas/Pubkey'
                room_id:
                    format: uuid
                    type: string
                room_name:
                    type: string
            type: object
        RoomSummary:
            properties:
                createdAt:
                    format: date-time
                    type: string
                description:
                    type: string
                name:
                    type: string
                numParticipants:
                    type: integer
                pictureUrl:
                    type: string
                sid:
                    type: string
                status:
                    type: string
            type: object
        RoomToggle:
            properties:
                enabled:
                    type: boolean
                roomName:
                    type: string
            required:
                - roomName
                - enabled
            type: object
        SubscribeSettled:
            properties:
                intent_id:
                    format: uuid
                    type: string
                status:
                    enum:
                        - settled
                    type: string
                subscription:
                    $ref: '#/components/schemas/Subscription'
            required:
                - status
                - intent_id
                - subscription
            type: object
        Subscription:
            properties:
                can_record:
                    type: boolean
                entitled:
                    description: Whether the gate currently lets this pubkey through.
                    type: boolean
                free_quota:
                    description: Dashboard-created rooms kept without a subscription.
                    type: integer
                grace_days:
                    type: integer
                in_grace:
                    type: boolean
                paid_until:
                    description: Null for perpetual admin/comp grants.
                    format: date-time
                    type:
                        - string
                        - "null"
                plan:
                    description: Omitted when the pubkey has never subscribed.
                    type: string
                pubkey:
                    $ref: '#/components/schemas/Pubkey'
                room_quota:
                    type: integer
                rooms_in_use:
                    type: integer
                status:
                    enum:
                        - none
                        - active
                        - expired
                        - cancelled
                    type: string
            type: object
        UsageWindow:
            properties:
                end_date:
                    format: date
                    type: string
                period:
                    type: string
                start_date:
                    format: date
                    type: string
            type: object
    securitySchemes:
        actionEvent:
            description: |
                `Authorization: Nostr <base64 kind-27235 event>` (raw JSON is also
                accepted). The event binds the method, the full URL including query
                string, and a `payload` tag carrying the hex SHA-256 of the exact raw
                request body — the empty string for a bodyless GET. Required tags:
                `payload`, `action` (one of `login`, `create-room`, `edit-room`,
                `subscribe`, `subscription`, `payment-status`), and `nonce`.

                The `nonce` is proven by echoing the **challenge JWT** in the
                `X-Challenge` header. That JWT comes from `GET /api/auth/challenge`.
                Nothing is stored server-side; the JWT signature is the proof of
                issuance. Nonces are single-use and expire in 5 minutes; the event-id
                replay cache is deliberately wider (10 minutes) so an event cannot be
                replayed after leaving the cache while still timestamp-valid.

                This is the mechanism used for `/api/auth/login`, lifecycle calls
                (`/api/register-room`, `/api/room/edit`), and the billing endpoints
                (`/api/subscribe`, `/api/payment/status`, `/api/subscription`).
            in: header
            name: Authorization
            type: apiKey
        clientAttestation:
            description: |
                A kind-27235 event signed by a trusted client key, proving *which
                client* is calling. Only clients in `TRUSTED_CLIENT_PUBKEYS` can
                produce one; third parties simply omit it and are gated accordingly.

                When used with a billing endpoint the dashboard proxy must also set
                `X-User-Pubkey` to the hex-pubkey of the logged-in user. The attestation
                says *which client*, the header says *which user*; the relay does not
                infer one from the other.
            in: header
            name: X-Client-Attestation
            type: apiKey
        livekitJWT:
            bearerFormat: JWT
            description: A LiveKit token minted by `/api/get-token`.
            scheme: bearer
            type: http
        livekitModeratorJWT:
            bearerFormat: JWT
            description: A LiveKit token whose `owner` or `moderator` claim is true.
            scheme: bearer
            type: http
        livekitOwnerJWT:
            bearerFormat: JWT
            description: A LiveKit token whose `owner` claim is true.
            scheme: bearer
            type: http
        nip98:
            description: |
                `Authorization: Nostr <base64 kind-27235 event>`, bound to the method,
                the full URL including query string, and the SHA-256 of the body.
                Single-use. Used by the lobby endpoints (`/api/room/lobby/request`,
                `/api/room/lobby/status`) and poll vote (`/api/room/poll/vote`); these
                do **not** require an `X-Challenge` header. Note: `/api/get-token` does
                **not** use this scheme — it reads the signed event from the request
                body (`attributes.signed_event`), not from the `Authorization` header.
            scheme: Nostr
            type: http
        sessionCookie:
            description: Issued by `/api/auth/login`; an alternative to signing each call.
            in: cookie
            name: hivetalk_session
            type: apiKey
info:
    description: |
        HiveRelay is a Khatru-based Nostr relay (NIP-42/NIP-53) plus the REST API
        that mints LiveKit tokens, owns the room registry, and sells the
        subscriptions that permit third-party clients to use it.

        ## Four kinds of identity

        - **Action event** (`Authorization: Nostr <base64 kind-27235 event>` +
          `X-Challenge: <challenge JWT>`) says *who the user is* on the billing
          and registry endpoints. The event is bound to the exact method, URL and
          SHA-256 of the body, and to a single-use nonce proven by the challenge
          JWT. Used by `/api/auth/login`, `/api/subscribe`, `/api/payment/status`,
          `/api/subscription`, `/api/register-room`, `/api/room/edit`.
        - **Body-based signed event** (`attributes.signed_event` in the request
          body + `pubkey` in the body) is the auth mechanism for `/api/get-token`
          only. The signed kind-27235 event is passed as a JSON-stringified string
          in `attributes.signed_event`, and `pubkey` in the body must equal the
          event's pubkey. No `Authorization` header, no `X-Challenge`. The server
          checks signature, `created_at` within ±5 min, and (only if
          `NIP98_STRICT=true`) the `u` and `method` tags.
        - **`X-Client-Attestation`** says *what client is calling*: the same event
          shape, signed by a key in the relay's `TRUSTED_CLIENT_PUBKEYS`. Only the
          dashboard holds such a key. Present-but-invalid is always `401`.
        - **LiveKit JWT** (`Authorization: Bearer <token>`) authorises in-room
          moderation. It is minted by `/api/get-token` and carries `owner` /
          `moderator` claims.

        Room lifecycle calls (`/api/auth/login`, `/api/register-room`,
        `/api/room/edit`) additionally require a fresh nonce from
        `/api/auth/challenge`, so a captured signature cannot be replayed.

        ## Access to a room

        Entitlement is read for the room's **owner**, never the caller: a
        subscriber's guests join from any client without subscriptions of their own.
        With `CLIENT_GATE_ENFORCED=true`, `/api/get-token` resolves the room name
        against the registry and answers:

        | Registry state | Attested (dashboard) | Not attested |
        |---|---|---|
        | active row, owner subscribed | 200 | 200 |
        | active row, owner on the free tier | 200 | 402 `subscription_required` |
        | active row, owner lapsed, within grace | oldest free-quota rooms 200, rest 402 `free_quota_exceeded` | 402 `subscription_expired` |
        | row exists but is not `active` | 403 `subscription_expired` | 403 `subscription_expired` |
        | no row (ephemeral) | 200 | 403 `room_not_registered` |

        A registry or billing lookup failure is `503`: the gate fails closed rather
        than granting access it could not verify.

        ## Payments and Subscriptions

        `GET /api/plans` lists what is for sale, `POST /api/subscribe` returns an
        L402 challenge with a BOLT11 invoice bound to your pubkey and plan, and
        `GET /api/payment/status` re-verifies settlement with the provider. There
        is no callback endpoint and no static LNURL — entitlement is granted only
        by matching a settled payment to its payment intent, and a fixed pay-link
        produces payments that belong to nobody.
    license:
        name: MIT
        url: https://github.com/HiveTalk/hiverelay/blob/main/LICENSE
    summary: Nostr relay and room-management API behind HiveTalk's LiveKit rooms.
    title: HiveRelay API
    version: 1.0.0
openapi: 3.1.0
paths:
    /:
        get:
            description: |
                HTML landing page. With an `Accept: application/nostr+json` header the
                Khatru relay answers the NIP-11 relay information document instead.
            operationId: getHome
            responses:
                "200":
                    content:
                        text/html:
                            schema:
                                type: string
                    description: HTML page, or the NIP-11 document.
            summary: Relay home page
            x-internal: true
    /api/auth/challenge:
        get:
            description: |
                Returns a short-lived signed challenge JWT and the nonce embedded in it.
                The client puts the `nonce` in the `nonce` tag of the kind-27235
                authorization event, then echoes the **challenge JWT itself** in the
                `X-Challenge` header of the same request. Nonces are single-use and
                expire in 5 minutes.
            operationId: getAuthChallenge
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    challenge:
                                        type: string
                                    domain:
                                        type: string
                                    expires_at:
                                        format: int64
                                        type: integer
                                    nonce:
                                        type: string
                                type: object
                    description: Challenge issued.
                "429":
                    $ref: '#/components/responses/RateLimited'
            summary: Get a single-use nonce
            tags:
                - Auth
    /api/auth/login:
        post:
            description: |
                Proves the nonce from `GET /api/auth/challenge` and sets the
                `hivetalk_session` cookie. The session is minted for the pubkey that
                signed the authorization event — there is no `pubkey` or `event` field
                in the body. Whatever the request body contains is hashed into the
                `payload` tag, so an empty body is the normal case.

                The authorization event uses `action=login` and the `nonce` from the
                challenge. The challenge JWT itself must be in the `X-Challenge` header.
            operationId: postAuthLogin
            parameters:
                - in: header
                  name: X-Challenge
                  required: true
                  schema:
                    type: string
            requestBody:
                content:
                    application/json:
                        schema:
                            type: object
                required: false
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    expires_at:
                                        format: int64
                                        type: integer
                                    pubkey:
                                        $ref: '#/components/schemas/Pubkey'
                                type: object
                    description: Session established; the cookie is in `Set-Cookie`.
                "400":
                    description: Could not read the request body.
                "401":
                    description: The event did not verify, or the nonce was stale or already used.
                "405":
                    description: Method not allowed.
                "429":
                    $ref: '#/components/responses/RateLimited'
                "500":
                    description: Could not mint the session.
            security:
                - actionEvent: []
            summary: Exchange a signed challenge for a session cookie
            tags:
                - Auth
    /api/auth/logout:
        post:
            operationId: postAuthLogout
            responses:
                "200":
                    description: Cookie cleared.
            summary: Clear the session cookie
            tags:
                - Auth
    /api/events:
        get:
            operationId: getEvents
            parameters:
                - in: query
                  name: kind
                  schema:
                    enum:
                        - 5
                        - 22242
                        - 30000
                        - 30078
                        - 30311
                        - 30312
                        - 30313
                    type: integer
                - in: query
                  name: limit
                  schema:
                    default: 50
                    maximum: 100
                    type: integer
                - in: query
                  name: pubkey
                  schema:
                    $ref: '#/components/schemas/Pubkey'
                - description: Without `kind`, returns the room's metadata instead of events.
                  in: query
                  name: roomName
                  schema:
                    type: string
                - in: query
                  name: identifier
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                oneOf:
                                    - items:
                                        $ref: '#/components/schemas/NostrEvent'
                                      type: array
                                    - additionalProperties: true
                                      type: object
                    description: Matching events, or one room metadata object.
                "429":
                    $ref: '#/components/responses/RateLimited'
            summary: Query stored Nostr events
            tags:
                - Nostr
    /api/get-token:
        post:
            description: |
                Resolves the room through the registry, applies the client gate, and
                returns a LiveKit JWT. For third-party clients this is
                permanent-rooms-only: an unregistered name is `403 room_not_registered`,
                because an ephemeral room has no owner to charge.

                The returned token grants publish rights according to the room's stage
                and audience-mode state, and carries `owner`/`moderator` claims used by
                the moderation endpoints.

                ## Authentication (body-based, NOT header-based)

                Unlike the billing/registry endpoints, `/api/get-token` does **not**
                read the `Authorization` header. The signed kind-27235 event is passed
                in the request body as `attributes.signed_event` (a JSON-stringified
                event string — not base64), and `pubkey` in the body must equal the
                event's pubkey. No `X-Challenge` header, no nonce.

                The server checks: signature, `created_at` within ±5 min, and (only if
                `NIP98_STRICT=true`) the `u` and `method` tags. The `payload`/body-hash
                tag is not checked on this endpoint.

                Validation errors return `text/plain` (not JSON):
                - `400 pubkey is required` — `pubkey` omitted from the body.
                - `400 signed event is required` — `attributes.signed_event` missing
                  (means you are using the wrong auth mechanism — the header is not read).
                - `400 invalid signed event format` — the `signed_event` value is not
                  valid JSON (e.g. you sent base64 instead of raw JSON).
                - `400 event pubkey does not match request pubkey` — body `pubkey` ≠
                  event pubkey.
            operationId: postGetToken
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                attributes:
                                    additionalProperties:
                                        type: string
                                    description: |
                                        Must include `signed_event`: a JSON-stringified kind-27235
                                        Nostr event signed by the caller, with tags `["u", fullUrl]`
                                        and `["method", "POST"]`. Not base64 — raw JSON string.
                                    type: object
                                participantName:
                                    type: string
                                pubkey:
                                    $ref: '#/components/schemas/Pubkey'
                                    description: The caller's Nostr pubkey. Required — must equal the pubkey of the signed event in attributes.signed_event.
                                roomName:
                                    description: Public room name, canonicalised by the relay.
                                    type: string
                            required:
                                - roomName
                                - participantName
                                - pubkey
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    token:
                                        type: string
                                    url:
                                        description: LiveKit websocket URL.
                                        type: string
                                type: object
                    description: Token minted.
                "400":
                    description: |
                        Validation error (text/plain, not JSON). `pubkey is required`,
                        `signed event is required`, `invalid signed event format`, or
                        `event pubkey does not match request pubkey`.
                "401":
                    $ref: '#/components/responses/AttestationFailed'
                "402":
                    $ref: '#/components/responses/PaymentRequired'
                "403":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/GateError'
                    description: |
                        `room_not_registered` (unregistered room, unattested caller) or
                        `subscription_expired` (the room's registry row is no longer active).
                "429":
                    $ref: '#/components/responses/RateLimited'
                "503":
                    $ref: '#/components/responses/GateUnavailable'
            security:
                - clientAttestation: []
                - {}
            summary: Mint a LiveKit access token
            tags:
                - Rooms
    /api/kick-user:
        post:
            operationId: postKickUser
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ParticipantAction'
                required: true
            responses:
                "200":
                    description: Participant removed.
                "401":
                    description: Missing or invalid JWT.
                "403":
                    description: Not a moderator, or the target is protected.
            security:
                - livekitModeratorJWT: []
            summary: Remove a participant
            tags:
                - Moderation
    /api/list-rooms:
        get:
            operationId: getListRooms
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                items:
                                    $ref: '#/components/schemas/RoomSummary'
                                type: array
                    description: Live LiveKit rooms with their metadata.
                "429":
                    $ref: '#/components/responses/RateLimited'
            summary: List live rooms
            tags:
                - Rooms
    /api/list-users:
        get:
            operationId: getListUsers
            parameters:
                - in: query
                  name: roomName
                  required: true
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                items:
                                    additionalProperties: true
                                    type: object
                                type: array
                    description: Participants currently connected.
                "429":
                    $ref: '#/components/responses/RateLimited'
            summary: List participants of a live room
            tags:
                - Rooms
    /api/mute-user:
        post:
            operationId: postMuteUser
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ParticipantAction'
                required: true
            responses:
                "200":
                    description: Participant muted.
                "401":
                    description: Missing or invalid JWT.
                "403":
                    description: Not a moderator, or the target is protected.
            security:
                - livekitModeratorJWT: []
            summary: Mute a participant
            tags:
                - Moderation
    /api/owner-room-sessions:
        post:
            operationId: postOwnerRoomSessions
            requestBody:
                content:
                    application/json:
                        schema:
                            allOf:
                                - $ref: '#/components/schemas/UsageWindow'
                                - properties:
                                    pubkey:
                                        $ref: '#/components/schemas/Pubkey'
                                  required:
                                    - pubkey
                                  type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                additionalProperties: true
                                type: object
                    description: Sessions for the owner's rooms.
                "403":
                    description: The JWT is for a different pubkey.
            security:
                - livekitJWT: []
            summary: Sessions across the rooms you own
            tags:
                - Analytics
    /api/payment/status:
        get:
            description: |
                Re-verifies settlement with the provider rather than trusting any
                claim, and activates the entitlement in the same transaction that marks
                the intent settled. Safe to call repeatedly; a second settled poll does
                not grant twice. A payment nobody stays to poll for is picked up by the
                background reconciler within a couple of minutes.

                Another caller's intent returns **404, not 403**: intent ids are not
                enumerable and existence is not the caller's business.
            operationId: getPaymentStatus
            parameters:
                - in: query
                  name: id
                  required: true
                  schema:
                    format: uuid
                    type: string
                - description: Required when using an action event.
                  in: header
                  name: X-Challenge
                  required: true
                  schema:
                    type: string
                - description: Required when using Client Attestation.
                  in: header
                  name: X-User-Pubkey
                  required: false
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PaymentStatus'
                    description: Intent status, plus the subscription once it settles.
                "400":
                    description: '`id` is required.'
                "401":
                    description: Not identified.
                "404":
                    description: No such intent for this caller.
                "429":
                    $ref: '#/components/responses/RateLimited'
                "500":
                    description: Database error.
            security:
                - sessionCookie: []
                - actionEvent: []
                - clientAttestation: []
            summary: Poll a payment intent
            tags:
                - Billing
    /api/plans:
        get:
            description: Prices come from the relay's environment, so they are deployment-specific.
            operationId: getPlans
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    free_quota:
                                        description: The dashboard free-tier room allowance.
                                        type: integer
                                    plans:
                                        items:
                                            $ref: '#/components/schemas/Plan'
                                        type: array
                                type: object
                    description: Plans for sale and the dashboard free-tier allowance.
            summary: Plan catalogue
            tags:
                - Billing
    /api/register-room:
        post:
            description: |
                Creates the registry row that makes a room permanent: it fixes the
                owner, the immutable `room_id` and the privacy flag, and it is what the
                client gate reads. The name must be URL-safe — letters and digits from
                any script plus `-` and `_`, no spaces or dots — and cannot be a name
                currently live as an ephemeral room.

                The authorization event uses `action=create-room` and the `nonce` from
                `GET /api/auth/challenge`; the challenge JWT goes in `X-Challenge`. The
                caller's pubkey is the owner — there is no `owner_pubkey` body field.
                An optional kind-30312 in `event` supplies the snapshot and is
                persisted only **after** the registry commit; a `room` tag in it, if
                present, must match `room_name`.

                Free-tier owners get a small quota of dashboard-created rooms;
                subscribers get their plan's quota. An unattested caller must be
                subscribed.
            operationId: postRegisterRoom
            parameters:
                - in: header
                  name: X-Challenge
                  required: true
                  schema:
                    type: string
                - description: Sets provenance to `dashboard`; present-but-invalid is `401`.
                  in: header
                  name: X-Client-Attestation
                  required: false
                  schema:
                    type: string
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                event:
                                    allOf:
                                        - $ref: '#/components/schemas/NostrEvent'
                                        - description: |
                                            Optional signed kind-30312. `status=private` sets
                                            `is_private`; `p` tags set the mod list; `d` tag sets
                                            `d_tag`.
                                room_name:
                                    type: string
                            required:
                                - room_name
                            type: object
                required: true
            responses:
                "201":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RegisteredRoom'
                    description: |
                        Room registered. `broadcast_pending` is true when the registry row
                        was written but the kind-30312 announcement has not gone out yet.
                "400":
                    description: Malformed request, invalid name, or a bad kind-30312.
                "401":
                    $ref: '#/components/responses/AttestationFailed'
                "402":
                    $ref: '#/components/responses/PaymentRequired'
                "409":
                    description: The name is taken, or live as an ephemeral room, or the room/30312 tag does not match room_name.
                "429":
                    $ref: '#/components/responses/RateLimited'
                "500":
                    description: Database error.
                "503":
                    $ref: '#/components/responses/GateUnavailable'
            security:
                - actionEvent: []
            summary: Claim a room name
            tags:
                - Registry
    /api/room-info:
        get:
            description: |
                Resolves aliases to the room's canonical name. A registered room answers
                from the registry; an unregistered name is `404`, which is how a client
                distinguishes a permanent room from an ephemeral one.
            operationId: getRoomInfo
            parameters:
                - in: query
                  name: room_name
                  required: true
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RoomInfo'
                    description: Room metadata.
                "404":
                    description: No such room.
                "429":
                    $ref: '#/components/responses/RateLimited'
            summary: Room metadata by name
            tags:
                - Rooms
    /api/room-sessions:
        post:
            operationId: postRoomSessions
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/UsageWindow'
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                additionalProperties: true
                                type: object
                    description: Sessions in the window.
            summary: Sessions per period
            tags:
                - Analytics
    /api/room/audience-mode:
        post:
            description: |
                Restricts publishing to staff and stage. Toggling reconciles
                participants already in the room, not just future joins.
            operationId: postRoomAudienceMode
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/RoomToggle'
                required: true
            responses:
                "200":
                    description: New state.
                "403":
                    description: Owner only.
            security:
                - livekitOwnerJWT: []
            summary: Toggle audience mode
            tags:
                - Moderation
    /api/room/delete:
        post:
            description: |
                Owner-only cascade delete. `confirm` must echo `room_name`, an
                intentional speed bump on an irreversible action. The LiveKit room,
                access policies, stage membership, lobby requests, polls and the Nostr
                announcement are all deleted. `public.recordings` rows are deliberately
                preserved so the owner keeps access to past recordings.
            operationId: postRoomDelete
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                confirm:
                                    description: Must equal `room_name`.
                                    type: string
                                room_name:
                                    type: string
                            required:
                                - room_name
                                - confirm
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    deleted:
                                        type: boolean
                                    events_removed:
                                        format: int64
                                        type: integer
                                    livekit_deleted:
                                        type: boolean
                                    room_name:
                                        type: string
                                type: object
                    description: Deleted.
                "400":
                    description: Missing room_name or confirm mismatch.
                "401":
                    description: Missing or invalid owner JWT.
                "403":
                    description: Not the room's owner, or the room is not permanent.
                "405":
                    description: Method not allowed.
                "429":
                    $ref: '#/components/responses/RateLimited'
                "500":
                    description: Database error.
            security:
                - livekitOwnerJWT: []
            summary: Delete a permanent room
            tags:
                - Registry
    /api/room/edit:
        post:
            description: |
                Changes the public alias only. `room_id` is immutable, so live tokens
                stay valid across a rename and a rename does not consume quota. The
                room is identified by `room_id` when present, otherwise by
                `room_name`.

                The authorization event uses `action=edit-room` and a fresh nonce. The
                optional kind-30312 `event`'s `room` tag, if present, must match
                `new_name`.
            operationId: postRoomEdit
            parameters:
                - in: header
                  name: X-Challenge
                  required: true
                  schema:
                    type: string
                - description: Sets provenance to `dashboard`; present-but-invalid is `401`.
                  in: header
                  name: X-Client-Attestation
                  required: false
                  schema:
                    type: string
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                event:
                                    allOf:
                                        - $ref: '#/components/schemas/NostrEvent'
                                        - description: Optional new kind-30312, refreshes the snapshot.
                                new_name:
                                    type: string
                                room_id:
                                    format: uuid
                                    type: string
                                room_name:
                                    description: Current name; an alternative to `room_id`.
                                    type: string
                            required:
                                - new_name
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RegisteredRoom'
                    description: Renamed.
                "400":
                    description: Malformed request, invalid name, or a bad kind-30312.
                "401":
                    $ref: '#/components/responses/AttestationFailed'
                "402":
                    $ref: '#/components/responses/PaymentRequired'
                "403":
                    description: Not the room's owner.
                "404":
                    description: Room not registered.
                "409":
                    description: The new name is taken, or live as an ephemeral room.
                "429":
                    $ref: '#/components/responses/RateLimited'
                "500":
                    description: Database error.
                "503":
                    $ref: '#/components/responses/GateUnavailable'
            security:
                - actionEvent: []
            summary: Rename a room
            tags:
                - Registry
    /api/room/lobby/approve:
        post:
            description: Grants a time-limited override that survives the room staying locked.
            operationId: postRoomLobbyApprove
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/LobbyDecision'
                required: true
            responses:
                "200":
                    description: Admitted.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Admit an applicant
            tags:
                - Lobby
    /api/room/lobby/deny:
        post:
            operationId: postRoomLobbyDeny
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/LobbyDecision'
                required: true
            responses:
                "200":
                    description: Denied.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Deny an applicant
            tags:
                - Lobby
    /api/room/lobby/enable:
        post:
            operationId: postRoomLobbyEnable
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/RoomToggle'
                required: true
            responses:
                "200":
                    description: New state.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Enable or disable the lobby
            tags:
                - Lobby
    /api/room/lobby/pending:
        post:
            operationId: postRoomLobbyPending
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                roomName:
                                    type: string
                            required:
                                - roomName
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                items:
                                    additionalProperties: true
                                    type: object
                                type: array
                    description: Pending requests.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: List pending admission requests
            tags:
                - Lobby
    /api/room/lobby/request:
        post:
            operationId: postRoomLobbyRequest
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                displayName:
                                    type: string
                                roomName:
                                    type: string
                            required:
                                - roomName
                            type: object
                required: true
            responses:
                "200":
                    description: Request recorded, or already pending.
                "401":
                    description: NIP-98 required.
                "403":
                    description: Previously denied.
                "429":
                    $ref: '#/components/responses/RateLimited'
            security:
                - nip98: []
            summary: Ask to be admitted to a locked room
            tags:
                - Lobby
    /api/room/lobby/status:
        post:
            operationId: postRoomLobbyStatus
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                roomName:
                                    type: string
                            required:
                                - roomName
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    status:
                                        enum:
                                            - pending
                                            - approved
                                            - denied
                                            - none
                                        type: string
                                type: object
                    description: Request state.
                "429":
                    $ref: '#/components/responses/RateLimited'
            security:
                - nip98: []
            summary: Poll your own admission request
            tags:
                - Lobby
    /api/room/lobby/undeny:
        post:
            operationId: postRoomLobbyUndeny
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/LobbyDecision'
                required: true
            responses:
                "200":
                    description: Denial cleared.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Clear a denial
            tags:
                - Lobby
    /api/room/moderator/demote:
        post:
            operationId: postRoomModeratorDemote
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ParticipantAction'
                required: true
            responses:
                "200":
                    description: Demoted.
                "403":
                    description: Owner only.
            security:
                - livekitOwnerJWT: []
            summary: Demote a moderator
            tags:
                - Moderation
    /api/room/moderator/promote:
        post:
            operationId: postRoomModeratorPromote
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ParticipantAction'
                required: true
            responses:
                "200":
                    description: Promoted.
                "403":
                    description: Owner only.
            security:
                - livekitOwnerJWT: []
            summary: Promote a participant to moderator
            tags:
                - Moderation
    /api/room/mute-on-join:
        post:
            operationId: postRoomMuteOnJoin
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/RoomToggle'
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    mute_on_join:
                                        type: boolean
                                    room_name:
                                        type: string
                                type: object
                    description: New state, also broadcast to the room.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Toggle mute-on-join
            tags:
                - Moderation
    /api/room/notify-lock:
        post:
            operationId: postRoomNotifyLock
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/RoomToggle'
                required: true
            responses:
                "200":
                    description: New state.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Lock or unlock a room
            tags:
                - Lobby
    /api/room/poll/active:
        get:
            operationId: getRoomPollActive
            parameters:
                - in: query
                  name: roomName
                  required: true
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                oneOf:
                                    - $ref: '#/components/schemas/PollState'
                                    - type: "null"
                    description: The open poll, or null.
            summary: The room's open poll, if any
            tags:
                - Polls
    /api/room/poll/close:
        post:
            operationId: postRoomPollClose
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                pollId:
                                    format: uuid
                                    type: string
                                roomName:
                                    type: string
                            required:
                                - roomName
                                - pollId
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PollState'
                    description: Closed, with the final tally.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Close a poll
            tags:
                - Polls
    /api/room/poll/create:
        post:
            operationId: postRoomPollCreate
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                allowMultiple:
                                    type: boolean
                                options:
                                    items:
                                        type: string
                                    minItems: 2
                                    type: array
                                question:
                                    type: string
                                roomName:
                                    type: string
                                spotlight:
                                    type: boolean
                            required:
                                - roomName
                                - question
                                - options
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PollState'
                    description: Poll created and broadcast to the room.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Open a poll
            tags:
                - Polls
    /api/room/poll/vote:
        post:
            operationId: postRoomPollVote
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                optionIdx:
                                    type: integer
                                pollId:
                                    format: uuid
                                    type: string
                                roomName:
                                    type: string
                            required:
                                - roomName
                                - pollId
                                - optionIdx
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PollState'
                    description: Vote recorded.
                "409":
                    description: The poll is closed.
            security:
                - nip98: []
            summary: Cast a vote
            tags:
                - Polls
    /api/room/recording/delete:
        get:
            description: Only a recording in a terminal state can be deleted; stop it first.
            operationId: getRoomRecordingDelete
            parameters:
                - in: query
                  name: recording_id
                  required: true
                  schema:
                    format: uuid
                    type: string
            responses:
                "200":
                    description: Deleted.
                "403":
                    description: Owner only.
                "409":
                    description: Still recording.
            security:
                - livekitOwnerJWT: []
            summary: Delete a finished recording
            tags:
                - Recording
    /api/room/recording/download:
        get:
            description: |
                The URL is short-lived. Objects are deleted by the retention reaper
                after `RECORDING_RETENTION_HOURS`, so a link outlives the file it points at.
            operationId: getRoomRecordingDownload
            parameters:
                - in: query
                  name: recording_id
                  required: true
                  schema:
                    format: uuid
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                properties:
                                    expires_at:
                                        format: int64
                                        type: integer
                                    url:
                                        format: uri
                                        type: string
                                type: object
                    description: Presigned URL.
                "403":
                    description: Owner only.
                "404":
                    description: No such recording, or it has expired.
            security:
                - livekitOwnerJWT: []
            summary: Presigned download URL for a recording
            tags:
                - Recording
    /api/room/recording/list:
        get:
            operationId: getRoomRecordingList
            parameters:
                - in: query
                  name: room_name
                  required: true
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                items:
                                    $ref: '#/components/schemas/Recording'
                                type: array
                    description: Recordings, newest first.
                "403":
                    description: Owner only.
            security:
                - livekitOwnerJWT: []
            summary: List a room's recordings
            tags:
                - Recording
    /api/room/recording/start:
        post:
            description: |
                Requires the `EGRESS_ENABLED` operator switch and an active
                subscription for the room's owner. Output is a single audio-only MP4.
                Recordings are keyed on the registry's immutable `room_id`, so a rename
                mid-recording does not orphan the file.
            operationId: postRoomRecordingStart
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                roomName:
                                    type: string
                            required:
                                - roomName
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RecordingStart'
                    description: Recording started.
                "402":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/GateError'
                    description: The room's owner has no active subscription.
                "403":
                    description: Owner only.
                "409":
                    description: Already recording.
                "503":
                    description: |
                        Egress disabled, or the registry lookup failed — the entitlement
                        check fails closed rather than recording without one.
            security:
                - livekitOwnerJWT: []
            summary: Start recording a room
            tags:
                - Recording
    /api/room/recording/status:
        get:
            description: Public, so a client can show the recording banner before joining.
            operationId: getRoomRecordingStatus
            parameters:
                - in: query
                  name: room_name
                  required: true
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RecordingStatus'
                    description: Recording state.
            summary: Whether a room is being recorded
            tags:
                - Recording
    /api/room/recording/stop:
        post:
            description: |
                Idempotent. A recording already running when its subscription lapses is
                allowed to finish; only *starting* one is gated.
            operationId: postRoomRecordingStop
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                roomName:
                                    type: string
                            required:
                                - roomName
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RecordingStop'
                    description: Stopping.
                "403":
                    description: Owner only.
                "404":
                    description: Nothing is recording.
            security:
                - livekitOwnerJWT: []
            summary: Stop a recording
            tags:
                - Recording
    /api/room/stage/demote:
        post:
            operationId: postRoomStageDemote
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ParticipantAction'
                required: true
            responses:
                "200":
                    description: In the audience.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Move a participant back to the audience
            tags:
                - Moderation
    /api/room/stage/promote:
        post:
            description: Grants publish rights while the room is in audience mode.
            operationId: postRoomStagePromote
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ParticipantAction'
                required: true
            responses:
                "200":
                    description: On stage.
                "403":
                    description: Not the owner or a moderator.
            security:
                - livekitModeratorJWT: []
            summary: Move a participant to the stage
            tags:
                - Moderation
    /api/rooms-by-pubkey:
        get:
            description: |
                Lists the owner's registered rooms. Ownership comes from the registry,
                so a kind-30312 event with no active registration is not listed —
                publishing one does not make a room the relay will open.
            operationId: getRoomsByPubkey
            parameters:
                - in: query
                  name: pubkey
                  required: true
                  schema:
                    $ref: '#/components/schemas/Pubkey'
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                items:
                                    $ref: '#/components/schemas/OwnedRoom'
                                type: array
                    description: Rooms owned by that pubkey.
                "429":
                    $ref: '#/components/responses/RateLimited'
            summary: List rooms owned by a pubkey
            tags:
                - Rooms
    /api/subscribe:
        post:
            description: |
                Creates a payment intent bound to the caller's pubkey and the chosen
                plan. The initial response is an L402 challenge: the
                `WWW-Authenticate` header carries a macaroon and invoice, and the
                Invoice-shaped JSON body carries the same payment details. At most one
                intent is pending per pubkey: asking again returns the existing
                challenge rather than a second invoice. After paying, retry with the
                L402 macaroon and preimage; settlement is re-verified with the
                provider before the subscription is activated.
            operationId: postSubscribe
            parameters:
                - description: Required when using an action event.
                  in: header
                  name: X-Challenge
                  required: true
                  schema:
                    type: string
                - description: Required when using Client Attestation.
                  in: header
                  name: X-User-Pubkey
                  required: false
                  schema:
                    type: string
                - description: |
                    Optional `L402 <macaroon>:<preimage>` credentials for retries when
                    `Authorization` is needed for NIP-98 identity.
                  in: header
                  name: X-L402
                  required: false
                  schema:
                    type: string
            requestBody:
                content:
                    application/json:
                        schema:
                            properties:
                                plan:
                                    enum:
                                        - standard_1y
                                        - standard_2y
                                        - bulk10_1y
                                        - bulk10_2y
                                    type: string
                            required:
                                - plan
                            type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/SubscribeSettled'
                    description: Payment settled and the subscription is active.
                "400":
                    description: Invalid JSON or unknown plan.
                "401":
                    description: Not identified.
                "402":
                    content:
                        application/json:
                            schema:
                                oneOf:
                                    - $ref: '#/components/schemas/Invoice'
                                    - properties:
                                        error:
                                            enum:
                                                - payment_not_settled
                                            type: string
                                      required:
                                        - error
                                      type: object
                    description: |
                        An L402 payment challenge (`WWW-Authenticate: L402
                        macaroon="...", invoice="..."`) with the Invoice-shaped response
                        body. A retry whose provider has not confirmed settlement returns
                        `payment_not_settled` in the error body.
                    headers:
                        WWW-Authenticate:
                            description: L402 macaroon and BOLT11 invoice challenge.
                            schema:
                                type: string
                "409":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/GateError'
                    description: |
                        `downgrade_not_supported` — an entitled subscriber may not buy a
                        lower tier. An outstanding invoice is not a conflict: the existing
                        challenge is re-issued as a `402` instead.
                "429":
                    $ref: '#/components/responses/RateLimited'
                "500":
                    description: Database error.
                "502":
                    description: '`invoice_creation_failed` when the provider rejects the invoice (the intent is marked failed), or `settlement_check_failed` when provider re-verification fails.'
                "503":
                    description: '`payments_unavailable` when the Lightning provider is not configured.'
            security:
                - sessionCookie: []
                - actionEvent: []
                - clientAttestation: []
            summary: Buy a plan
            tags:
                - Billing
    /api/subscription:
        get:
            description: |
                Identified by session cookie, client attestation + `X-User-Pubkey`, or
                an action event. Read live from the database. There is no `pubkey`
                parameter — the caller can only read their own entitlement.
            operationId: getSubscription
            parameters:
                - description: Required when using Client Attestation.
                  in: header
                  name: X-User-Pubkey
                  required: false
                  schema:
                    type: string
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Subscription'
                    description: Current entitlement.
                "401":
                    description: Not identified.
                "429":
                    $ref: '#/components/responses/RateLimited'
                "500":
                    description: Database error.
            security:
                - sessionCookie: []
                - actionEvent: []
                - clientAttestation: []
            summary: The caller's entitlement
            tags:
                - Billing
    /api/usage-by-pubkey:
        post:
            description: The JWT's pubkey must match the pubkey being asked about.
            operationId: postUsageByPubkey
            requestBody:
                content:
                    application/json:
                        schema:
                            allOf:
                                - $ref: '#/components/schemas/UsageWindow'
                                - properties:
                                    pubkey:
                                        $ref: '#/components/schemas/Pubkey'
                                  required:
                                    - pubkey
                                  type: object
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                additionalProperties: true
                                type: object
                    description: That user's usage.
                "403":
                    description: The JWT is for a different pubkey.
            security:
                - livekitJWT: []
            summary: One user's usage
            tags:
                - Analytics
    /api/usage-total:
        post:
            operationId: postUsageTotal
            requestBody:
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/UsageWindow'
                required: true
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                additionalProperties: true
                                type: object
                    description: Aggregate usage.
            summary: Total participant-minutes
            tags:
                - Analytics
    /api/webhooks/livekit:
        post:
            description: |
                Receives LiveKit's signed webhooks and reconciles recording rows. The
                JWT is verified against the LiveKit API secret; this is not a payment
                callback, and the relay has none — entitlement is granted only by
                polling the Lightning provider.
            operationId: postWebhooksLivekit
            responses:
                "200":
                    description: Accepted.
                "401":
                    description: The LiveKit signature did not verify.
            summary: LiveKit egress and room events
            x-internal: true
    /docs:
        get:
            operationId: getDocs
            responses:
                "200":
                    content:
                        text/html:
                            schema:
                                type: string
                    description: HTML page rendering this document.
            summary: Rendered API reference
            tags:
                - Docs
    /images/{filename}:
        get:
            operationId: getImagesFilename
            parameters:
                - in: path
                  name: filename
                  required: true
                  schema:
                    type: string
            responses:
                "200":
                    description: Image bytes.
                "404":
                    description: No such image.
            summary: Static images
            x-internal: true
    /llms.txt:
        get:
            description: |
                A prose summary of the room model, the access rules and the client
                flow, for an agent building a client without reading the spec.
            operationId: getLlmsTxt
            responses:
                "200":
                    content:
                        text/plain:
                            schema:
                                type: string
                    description: Plain text.
            summary: Orientation for coding agents
            tags:
                - Docs
    /openapi.docs.json:
        get:
            description: |
                The same description as `/openapi.yaml` but as JSON with any operation
                flagged `x-internal: true` removed. The rendered `/docs` page fetches
                this so internal routes (home, static images, the LiveKit webhook) do
                not appear in the public reference. The full YAML at `/openapi.yaml`
                is the source of truth and is unchanged.
            operationId: getOpenapiDocsJson
            responses:
                "200":
                    content:
                        application/json:
                            schema:
                                type: string
                    description: The filtered OpenAPI description as JSON.
            summary: Filtered OpenAPI JSON for the rendered reference
            tags:
                - Docs
            x-internal: true
    /openapi.yaml:
        get:
            description: Embedded in the binary, so it always describes the running relay.
            operationId: getOpenapiYaml
            responses:
                "200":
                    content:
                        application/yaml:
                            schema:
                                type: string
                    description: The OpenAPI description.
            summary: This document
            tags:
                - Docs
security: []
servers:
    - description: This relay
      url: https://relay.hivetalk.org
    - description: Local development
      url: http://localhost:3334
tags:
    - description: Joining rooms and reading room metadata.
      name: Rooms
    - description: Claiming and renaming permanent rooms.
      name: Registry
    - description: Nonce challenge and session cookies.
      name: Auth
    - description: Plans, invoices and entitlement.
      name: Billing
    - description: In-room moderation, authorised by the LiveKit JWT.
      name: Moderation
    - description: Admission requests for locked rooms.
      name: Lobby
    - description: In-room polls. Free, and not subscription-gated.
      name: Polls
    - description: Room-composite egress. Starting one requires an entitlement.
      name: Recording
    - description: Usage and session aggregates.
      name: Analytics
    - description: Relay-level event queries.
      name: Nostr
    - description: The relay's own documentation.
      name: Docs
