{
  "openapi": "3.1.0",
  "info": {
    "title": "Watch Peak Party Public API",
    "version": "1.0.0",
    "summary": "Create and join synchronized watch-party rooms.",
    "description": "The Watch Peak Party public API is a room-signalling surface for the Watch Peak Party browser extension. It creates a private room around one streaming video URL, resolves invite links, and issues short-lived tickets for the WebSocket channel that carries playback and WebRTC signalling.\n\nIt is not a content API: it never stores, proxies or serves video, and it has no catalogue to search. Every participant plays the title from their own account on the streaming service.\n\nAll responses are `application/json`. Failures use the `Error` schema with a stable machine-readable `code`. Rate-limited endpoints advertise their budget in RFC 9331 `RateLimit-*` headers and send `Retry-After` on a 429 — read those headers and self-throttle instead of retrying blind.\n\n## Versioning\n\nThe API is versioned in the URL path. Version 1 is current and is served from `https://beta.watchpeakparty.fun/v1`. Every response carries `API-Version` naming the version that answered and `API-Supported-Versions` listing everything this deployment implements. A `/v<N>/` prefix for a version that is not implemented is refused with `404` and `code: \"unsupported_version\"` rather than a bare not-found.\n\nThe same routes are also reachable unversioned, under `/api/` — `/api/rooms` is `/v1/rooms`. That alias exists because installed copies of the browser extension call it and cannot be forced to update. It is pinned to version 1 semantics permanently and is **not** deprecated, but new integrations should use `/v1/` so that a future `/v2/` is an opt-in.\n\n## Deprecation policy\n\nA breaking change ships as a new version prefix; `/v1/` is never altered underneath you. When a route or a version is scheduled for removal it is announced in the response itself, not only in prose: `Deprecation` (RFC 9745) carries the timestamp the deprecation took effect, `Sunset` (RFC 8594) the date the route stops answering, and a `Link` with `rel=\"deprecation\"` points at the migration notes. Minimum notice between the two is 180 days. Nothing is deprecated today, so none of these headers is currently emitted — if you see one, act on it.\n\n## Errors\n\nEvery failure is JSON. By default it is `{\"ok\": false, \"error\": \"...\", \"code\": \"...\"}`. Send `Accept: application/problem+json` and the identical failure is returned as an RFC 9457 problem document instead, with the same `code` carried through as an extension member. Branch on `code` in either representation; the human-readable text may be reworded.",
    "termsOfService": "https://watchpeakparty.fun/terms/",
    "contact": {
      "name": "Watch Peak Party support",
      "email": "help@watchpeakparty.fun",
      "url": "https://watchpeakparty.fun/contact/"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://watchpeakparty.fun/terms/"
    }
  },
  "servers": [
    {
      "url": "https://beta.watchpeakparty.fun/v1",
      "description": "Production signalling service, API version 1"
    }
  ],
  "externalDocs": {
    "description": "API reference",
    "url": "https://watchpeakparty.fun/docs/"
  },
  "tags": [
    {
      "name": "Service",
      "description": "Service health and capability reporting."
    },
    {
      "name": "Rooms",
      "description": "Creating rooms, resolving invites, and authorizing the realtime channel."
    },
    {
      "name": "Realtime",
      "description": "WebRTC transport configuration for voice and camera."
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "Service"
        ],
        "summary": "Report service health",
        "description": "Returns the signalling service status and which optional subsystems are configured. Unauthenticated. Rate limited generously (600 per IP per hour) so that the RFC 9331 RateLimit-* headers are observable on the first endpoint a client probes; a monitor polling less often than once a second will never notice the ceiling.",
        "security": [],
        "responses": {
          "200": {
            "description": "The service is reachable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              },
              "API-Supported-Versions": {
                "$ref": "#/components/headers/ApiSupportedVersions"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "429": {
            "description": "The per-IP hourly budget for this endpoint is exhausted. Wait for Retry-After seconds.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "500": {
            "description": "The request failed inside the service.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          }
        }
      }
    },
    "/rooms": {
      "post": {
        "operationId": "createRoom",
        "tags": [
          "Rooms"
        ],
        "summary": "Create a watch-party room",
        "description": "Creates a room around one supported streaming video URL and returns the room id, the join secret, and a ready-to-share invite URL. The room expires after ROOM_TTL_SECONDS (6 hours by default). Rate limited to 20 rooms per IP per hour.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateRoomRequest"
              },
              "example": {
                "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
                "userId": "host-42-abcdef",
                "displayName": "Aazan"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The room was created.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateRoomResponse"
                }
              }
            }
          },
          "400": {
            "description": "The request body was missing, malformed, or named an unsupported video URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "429": {
            "description": "The per-IP hourly budget for this endpoint is exhausted. Wait for Retry-After seconds.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "500": {
            "description": "The request failed inside the service.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "503": {
            "description": "The service could not complete the request right now — a room identifier could not be allocated, or a dependency is unconfigured. Safe to retry with backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          }
        }
      }
    },
    "/join/{roomId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/RoomId"
        }
      ],
      "post": {
        "operationId": "joinRoom",
        "tags": [
          "Rooms"
        ],
        "summary": "Resolve an invite into a target URL",
        "description": "Exchanges a room id and its join secret for the streaming URL the joining viewer should open, with the party fragment already appended. Does not admit the viewer to the realtime channel — call authorizeRoomSocket for that.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JoinRoomRequest"
              },
              "example": {
                "joinSecret": "6f1c9f0e5b3a4d2e8c7b1a0f9e8d7c6b"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The invite is valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JoinRoomResponse"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "400": {
            "description": "The request body was missing, malformed, or named an unsupported video URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "410": {
            "description": "The invite secret does not match, or the room has already expired. Rooms live for six hours.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "500": {
            "description": "The request failed inside the service.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          }
        }
      }
    },
    "/rooms/{roomId}/socket-ticket": {
      "parameters": [
        {
          "$ref": "#/components/parameters/RoomId"
        }
      ],
      "post": {
        "operationId": "authorizeRoomSocket",
        "tags": [
          "Rooms"
        ],
        "summary": "Issue a WebSocket ticket",
        "description": "Verifies the join secret and returns a single-use WebSocket URL whose ticket expires after SOCKET_TICKET_TTL_SECONDS (90 seconds by default). The channel behind it carries live playback and WebRTC signalling for human participants; connect only from the extension. Rate limited to 120 attempts per IP per hour.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SocketTicketRequest"
              },
              "example": {
                "joinSecret": "6f1c9f0e5b3a4d2e8c7b1a0f9e8d7c6b",
                "userId": "guest-7-abcdef",
                "displayName": "Sara"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A ticketed WebSocket URL was issued.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SocketTicketResponse"
                }
              }
            }
          },
          "400": {
            "description": "The request body was missing, malformed, or named an unsupported video URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "403": {
            "description": "The join secret did not match the room.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "404": {
            "description": "No such route, or the room has expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "429": {
            "description": "The per-IP hourly budget for this endpoint is exhausted. Wait for Retry-After seconds.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "500": {
            "description": "The request failed inside the service.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          }
        }
      }
    },
    "/turn-credentials": {
      "post": {
        "operationId": "getTurnCredentials",
        "tags": [
          "Realtime"
        ],
        "summary": "Fetch ICE servers for voice and camera",
        "description": "Returns short-lived STUN/TURN ICE server entries for the WebRTC voice and camera session. Falls back to free STUN with a warning when TURN is not configured. Credentials are issued only to members of a live room: send the roomId and joinSecret of a party you have already created or joined. Rate limiting alone cannot protect this endpoint, because it keys on client IP and an attacker with a single IPv6 /64 allocation controls 18 quintillion of them. Credentials expire after TURN_TTL_SECONDS (8 hours by default). Rate limited to 30 requests per IP per hour.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TurnCredentialsRequest"
              },
              "example": {
                "roomId": "K3M7QP2R9T",
                "joinSecret": "6f1c9f0e5b3a4d2e8c7b1a0f9e8d7c6b"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ICE servers were issued.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TurnCredentialsResponse"
                }
              }
            }
          },
          "400": {
            "description": "The request body was missing, malformed, or named an unsupported video URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "410": {
            "description": "The invite secret does not match, or the room has already expired. Rooms live for six hours.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "429": {
            "description": "The per-IP hourly budget for this endpoint is exhausted. Wait for Retry-After seconds.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "500": {
            "description": "The request failed inside the service.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "headers": {
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "RoomId": {
        "name": "roomId",
        "in": "path",
        "required": true,
        "description": "The room identifier returned by createRoom.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Z0-9]{8,16}$",
          "examples": [
            "K3M7QP2R9T"
          ]
        }
      }
    },
    "headers": {
      "RateLimitLimit": {
        "description": "Requests allowed in the current window for this endpoint and client IP (RFC 9331).",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      },
      "RateLimitRemaining": {
        "description": "Requests still available in the current window (RFC 9331).",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "RateLimitReset": {
        "description": "Seconds until the current window resets (RFC 9331).",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying, sent on every 429.",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "ApiVersion": {
        "description": "The API version that produced this response.",
        "schema": {
          "type": "string",
          "examples": [
            "1"
          ]
        }
      },
      "ApiSupportedVersions": {
        "description": "Every API version this deployment implements, comma separated.",
        "schema": {
          "type": "string",
          "examples": [
            "1"
          ]
        }
      },
      "Deprecation": {
        "description": "RFC 9745. Present only on a deprecated route: `@<unix-seconds>` at which the deprecation took effect.",
        "schema": {
          "type": "string",
          "examples": [
            "@1789000000"
          ]
        }
      },
      "Sunset": {
        "description": "RFC 8594. Present only on a deprecated route: the IMF-fixdate after which it stops answering.",
        "schema": {
          "type": "string",
          "format": "date-time",
          "examples": [
            "Wed, 01 Jul 2026 00:00:00 GMT"
          ]
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request body was missing, malformed, or named an unsupported video URL.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        },
        "headers": {
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        }
      },
      "Forbidden": {
        "description": "The join secret did not match the room.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        },
        "headers": {
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        }
      },
      "NotFound": {
        "description": "No such route, or the room has expired.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        },
        "headers": {
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        }
      },
      "RateLimited": {
        "description": "The per-IP hourly budget for this endpoint is exhausted. Wait for Retry-After seconds.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          },
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "The service could not complete the request right now — a room identifier could not be allocated, or a dependency is unconfigured. Safe to retry with backoff.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        },
        "headers": {
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        }
      },
      "ServerError": {
        "description": "The request failed inside the service.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        },
        "headers": {
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        }
      },
      "InviteExpired": {
        "description": "The invite secret does not match, or the room has already expired. Rooms live for six hours.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        },
        "headers": {
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        }
      },
      "UnsupportedVersion": {
        "description": "The /v<N>/ prefix names an API version this deployment does not implement.",
        "headers": {
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          },
          "API-Supported-Versions": {
            "$ref": "#/components/headers/ApiSupportedVersions"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    },
    "schemas": {
      "Platform": {
        "type": "string",
        "description": "The streaming service a room is pinned to, derived from the video URL.",
        "enum": [
          "youtube",
          "netflix",
          "primevideo",
          "disneyplus",
          "hulu",
          "hbomax",
          "crunchyroll",
          "jiohotstar"
        ]
      },
      "Error": {
        "type": "object",
        "description": "The shape of every failed response.",
        "required": [
          "ok",
          "error",
          "code"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": false,
            "description": "Always false on a failure."
          },
          "error": {
            "type": "string",
            "description": "Human-readable message, safe to show to an end user."
          },
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          }
        },
        "examples": [
          {
            "ok": false,
            "error": "Too many rooms created from this network recently. Please try again later.",
            "code": "rate_limited"
          }
        ]
      },
      "Health": {
        "type": "object",
        "required": [
          "ok",
          "service",
          "version"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "True when the service is serving requests."
          },
          "service": {
            "type": "string",
            "description": "Service identifier."
          },
          "version": {
            "type": "string",
            "description": "Deployed release version."
          },
          "durableObjects": {
            "type": "boolean",
            "description": "Whether the room store is bound."
          },
          "turnConfigured": {
            "type": "boolean",
            "description": "Whether managed TURN credentials are available."
          },
          "firebaseSocialConfigured": {
            "type": "boolean",
            "description": "Whether the friends/chat backend is configured."
          }
        }
      },
      "CreateRoomRequest": {
        "type": "object",
        "required": [
          "videoUrl",
          "userId",
          "displayName"
        ],
        "properties": {
          "videoUrl": {
            "type": "string",
            "format": "uri",
            "description": "A watch URL on a supported service. Must point at a specific title, not a browse or search page."
          },
          "userId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{8,64}$",
            "minLength": 8,
            "maxLength": 64,
            "description": "Caller-chosen stable identifier for this participant. 8-64 characters of A-Z, a-z, 0-9, hyphen or underscore.",
            "examples": [
              "host-42-abcdef"
            ]
          },
          "displayName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 32,
            "description": "Name shown to other participants. Trimmed and truncated to 32 characters; control characters are stripped.",
            "examples": [
              "Aazan"
            ]
          }
        }
      },
      "Room": {
        "type": "object",
        "required": [
          "roomId",
          "joinSecret",
          "platform",
          "videoUrl",
          "expiresAt",
          "inviteUrl"
        ],
        "properties": {
          "roomId": {
            "type": "string",
            "pattern": "^[A-Z0-9]{8,16}$",
            "description": "Room identifier."
          },
          "joinSecret": {
            "type": "string",
            "description": "Secret that admits a viewer. Share only through the invite URL."
          },
          "platform": {
            "$ref": "#/components/schemas/Platform"
          },
          "videoUrl": {
            "type": "string",
            "format": "uri",
            "description": "Canonicalized watch URL for the title."
          },
          "videoKey": {
            "type": "string",
            "description": "Platform-scoped title key, e.g. \"youtube:dQw4w9WgXcQ\"."
          },
          "hostUserId": {
            "type": "string",
            "description": "Identifier of the host who created the room."
          },
          "expiresAt": {
            "type": "integer",
            "format": "int64",
            "description": "Room expiry as Unix epoch milliseconds."
          },
          "inviteUrl": {
            "type": "string",
            "format": "uri",
            "description": "Shareable link that opens the title and joins the room."
          },
          "initialWebsocketUrl": {
            "type": "string",
            "format": "uri",
            "description": "Ticketed WebSocket URL for the host, valid for 90 seconds."
          }
        }
      },
      "CreateRoomResponse": {
        "type": "object",
        "required": [
          "ok",
          "room"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "room": {
            "$ref": "#/components/schemas/Room"
          }
        }
      },
      "JoinRoomRequest": {
        "type": "object",
        "required": [
          "joinSecret"
        ],
        "properties": {
          "joinSecret": {
            "type": "string",
            "description": "The join secret carried by the invite URL fragment."
          }
        }
      },
      "JoinRoomResponse": {
        "type": "object",
        "required": [
          "ok",
          "targetUrl"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "targetUrl": {
            "type": "string",
            "format": "uri",
            "description": "The URL the joining viewer should open."
          }
        }
      },
      "SocketTicketRequest": {
        "type": "object",
        "required": [
          "joinSecret",
          "userId",
          "displayName"
        ],
        "properties": {
          "joinSecret": {
            "type": "string",
            "description": "The room's join secret."
          },
          "userId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{8,64}$",
            "minLength": 8,
            "maxLength": 64,
            "description": "Caller-chosen stable identifier for this participant. 8-64 characters of A-Z, a-z, 0-9, hyphen or underscore.",
            "examples": [
              "host-42-abcdef"
            ]
          },
          "displayName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 32,
            "description": "Name shown to other participants. Trimmed and truncated to 32 characters; control characters are stripped.",
            "examples": [
              "Aazan"
            ]
          }
        }
      },
      "SocketTicketResponse": {
        "type": "object",
        "required": [
          "ok",
          "websocketUrl"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "websocketUrl": {
            "type": "string",
            "format": "uri",
            "description": "Single-use wss:// URL including the signed ticket."
          }
        }
      },
      "IceServer": {
        "type": "object",
        "required": [
          "urls"
        ],
        "properties": {
          "urls": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "STUN or TURN endpoints."
          },
          "username": {
            "type": "string",
            "description": "Short-lived TURN username, when TURN is configured."
          },
          "credential": {
            "type": "string",
            "description": "Short-lived TURN credential, when TURN is configured."
          }
        }
      },
      "TurnCredentialsRequest": {
        "type": "object",
        "required": [
          "roomId",
          "joinSecret"
        ],
        "properties": {
          "roomId": {
            "type": "string",
            "pattern": "^[A-Z0-9]{8,16}$",
            "description": "The room the caller is a member of."
          },
          "joinSecret": {
            "type": "string",
            "description": "That room's join secret, as carried by the invite URL fragment. Proves membership; never sent in a URL."
          }
        }
      },
      "TurnCredentialsResponse": {
        "type": "object",
        "required": [
          "ok",
          "iceServers",
          "expiresAt"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "iceServers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/IceServer"
            }
          },
          "expiresAt": {
            "type": "integer",
            "format": "int64",
            "description": "Credential expiry as Unix epoch milliseconds."
          },
          "warning": {
            "type": "string",
            "description": "Present when only free STUN is active."
          }
        }
      },
      "Problem": {
        "type": "object",
        "description": "RFC 9457 problem document. Returned in place of Error when the request carries `Accept: application/problem+json`.",
        "required": [
          "type",
          "title",
          "status",
          "detail",
          "code"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "description": "Stable URI identifying the problem type; resolves to the documentation for this error code."
          },
          "title": {
            "type": "string",
            "description": "Short, stable summary of the problem type. Identical for every occurrence."
          },
          "status": {
            "type": "integer",
            "description": "The HTTP status code, repeated for clients that only read the body."
          },
          "detail": {
            "type": "string",
            "description": "Human-readable explanation of this specific occurrence."
          },
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "instance": {
            "type": "string",
            "description": "The path this failure occurred on."
          },
          "documentation": {
            "type": "string",
            "format": "uri",
            "description": "Where the error vocabulary is documented."
          }
        },
        "examples": [
          {
            "type": "https://watchpeakparty.fun/docs/#error-rate_limited",
            "title": "Rate limit exceeded",
            "status": 429,
            "detail": "Too many rooms created from this network recently. Please try again later.",
            "code": "rate_limited",
            "instance": "/v1/rooms",
            "documentation": "https://watchpeakparty.fun/docs/#errors"
          }
        ]
      },
      "ErrorCode": {
        "type": "string",
        "description": "Stable machine-readable error code. Branch on this, never on the message text.",
        "enum": [
          "bad_request",
          "unauthorized",
          "forbidden",
          "not_found",
          "invite_expired",
          "upgrade_required",
          "rate_limited",
          "unsupported_version",
          "unavailable",
          "server_error"
        ]
      }
    }
  }
}
