{
  "openapi": "3.1.0",
  "info": {
    "title": "Watch Peak Party Public API",
    "version": "5.4.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 \u2014 read those headers and self-throttle instead of retrying blind.",
    "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",
      "description": "Production signalling service"
    }
  ],
  "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 and not rate limited; use it as a reachability probe before calling anything else.",
        "security": [],
        "responses": {
          "200": {
            "description": "The service is reachable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        }
      }
    },
    "/api/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"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateRoomResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/api/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 \u2014 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"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "410": {
            "$ref": "#/components/responses/InviteExpired"
          }
        }
      }
    },
    "/api/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"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SocketTicketResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/turn-credentials": {
      "get": {
        "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. Rate limited to 30 requests per IP per hour.",
        "security": [],
        "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"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TurnCredentialsResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "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
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request body was missing, malformed, or named an unsupported video URL.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The join secret did not match the room.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such route, or the room has expired.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "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"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "The service could not complete the request right now \u2014 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"
            }
          }
        }
      },
      "ServerError": {
        "description": "The request failed inside the service.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "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"
            }
          }
        }
      }
    },
    "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": {
            "type": "string",
            "description": "Stable machine-readable code. Branch on this, never on the message text.",
            "enum": [
              "bad_request",
              "unauthorized",
              "forbidden",
              "not_found",
              "invite_expired",
              "upgrade_required",
              "rate_limited",
              "unavailable",
              "server_error"
            ]
          }
        },
        "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."
          }
        }
      },
      "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."
          }
        }
      }
    }
  }
}
