{
  "openapi": "3.0.3",
  "info": {
    "title": "SPDN Public API",
    "version": "1.1.0",
    "summary": "Programmatic access to SPDN — create rooms, ingest live streams, embed them anywhere.",
    "description": "## What is SPDN?\n\nSPDN (Segment Pool Delivery Network) is a P2P-accelerated live streaming platform: viewers fetch segments from each other over WebRTC DataChannel and fall back to the origin only when the mesh can't help. The public API lets you create and manage rooms, ingest any live source (HLS, MPEG-TS, RTMP), build multi-file VOD playlists, and embed the player on your own pages.\n\n---\n\n## Quickstart\n\n```bash\n# 1. Mint a key from the dashboard once: https://spdn.tv/dashboard#developer\nexport SPDN_KEY=\"spdn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\n\n# 2. Create a room around your existing HLS source.\ncurl -X POST https://spdn.tv/api/v1/rooms \\\n  -H \"X-API-Key: $SPDN_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"streamUrl\": \"https://example.com/live/master.m3u8\",\n    \"name\": \"My Stream\",\n    \"allowedDomains\": [\"mysite.com\"]\n  }'\n\n# Response: { \"roomId\": \"AB3CDE\", \"creatorToken\": \"...\", ... }\n\n# 3. Embed it on mysite.com\n# <iframe src=\"https://spdn.tv/embed/AB3CDE\" allowfullscreen></iframe>\n\n# 4. Poll stats while it runs.\ncurl -H \"X-API-Key: $SPDN_KEY\" https://spdn.tv/api/v1/rooms/AB3CDE/stats\n```\n\nThat's the full happy path. The rest of this document is reference.\n\n---\n\n## Authentication\n\nEvery `/api/v1/*` request must carry an `X-API-Key` header. Keys are minted from **Dashboard → Developer**; the raw value is shown exactly once on creation — store it in your secret manager. Revoking a key from the dashboard takes effect immediately (subsequent requests return `401 api_key_required`).\n\nKeys are owner-scoped: a key created by user U can only see U's rooms (admin keys see everything). Plan caps (`maxRooms`, `rtmpAllowed`, embed-domain count) are re-evaluated on every mutating call against the key owner's current plan.\n\nThe cookie-based dashboard surface (`/api/my/*`) is intentionally **not** documented here — it is subject to UI iteration and not part of the public contract.\n\n---\n\n## Room Types\n\nA single endpoint — `POST /api/v1/rooms` — covers every single-source room type. SPDN auto-detects the type from the `streamUrl` suffix and `isRtmp` flag:\n\n| Source                              | How to create                                   | Edge behavior |\n|-------------------------------------|-------------------------------------------------|---------------|\n| HLS pull (any `.m3u8`)              | `streamUrl: \"https://.../master.m3u8\"`          | Edge proxies segments as viewers request them |\n| Live MPEG-TS (`.ts` continuous)     | `streamUrl: \"http://panel/live/key/123.ts\"`     | One ffmpeg ingests over a single connection, re-segments to HLS |\n| Live MPEG-DASH (`.mpd` manifest)    | `streamUrl: \"https://.../manifest.mpd\"`         | One ffmpeg demuxes DASH, remuxes to HLS — viewers stay on hls.js + P2P. **Non-DRM streams only**; codec must be H.264 + AAC. |\n| VOD file (`.mp4`, `.mkv`, etc.)     | `streamUrl: \"https://.../movie.mp4\"`            | Edge ffmpeg-segments the file; room ends naturally when it finishes |\n| RTMP ingest (you push from OBS)     | `isRtmp: true` (streamUrl ignored)              | Server mints a stream key + RTMP URL for your encoder |\n\nMulti-source VOD (a playlist of files concatenated as one continuous timeline) has its own endpoint: `POST /api/v1/rooms/playlist`.\n\n### Why a separate `.ts` ingest mode?\n\nXtream-style IPTV panels expose both `.m3u8` and `.ts` URLs for the same stream. Pulling the `.m3u8` means one HTTP request per segment — the panel counts each as a connection, so a single proxied stream can exhaust the account's connection cap. The `.ts` endpoint is a single continuous stream → one connection for the entire ingest. SPDN auto-detects this from the URL suffix and switches to ffmpeg-based ingest; viewers still get the HLS + P2P experience because the edge re-segments the output.\n\n---\n\n## Embedding\n\nEvery public room has an iframe endpoint:\n\n```html\n<iframe src=\"https://spdn.tv/embed/AB3CDE\"\n        width=\"960\" height=\"540\"\n        frameborder=\"0\" allowfullscreen></iframe>\n```\n\nEmbed access is gated by the room's `allowedDomains` list, enforced **two ways**:\n\n1. **Referer check** at request time — `/embed/{id}` returns 403 if `Referer` doesn't match an allowed domain (or its `*.allowed.com` wildcard).\n2. **CSP frame-ancestors** — the response sets `Content-Security-Policy: frame-ancestors <list>` so browsers refuse to render the iframe on non-allowed pages even if referer is spoofed.\n\nSetting `allowedDomains: []` (empty array) disables embed entirely. Plan caps the maximum domain count; downgraded customers receive `embed_not_allowed` on rooms whose domain list now exceeds their plan.\n\nRoot domains imply their subdomains (`example.com` matches `www.example.com` and `app.example.com`). Wildcards (`*.example.com`) are accepted explicitly.\n\n---\n\n## Rate Limiting\n\n- **Bucket**: per-user (NOT per-key).  Two keys minted by the same user share one bucket.\n- **Refill**: 1 request per second sustained.\n- **Burst**: 30 requests.\n- **Response**: 429 with `Retry-After` (seconds) when exhausted.\n\nThe limiter is intentionally generous for foreground polling and conservative for spike traffic.\n\n---\n\n## Errors\n\nEvery non-2xx body is a JSON envelope with a stable machine-readable `error` token. Always branch on `error`, never on the human `message`. Full sentinel table:\n\n| HTTP | error                       | Where |\n|------|-----------------------------|-------|\n| 400  | `invalid_json`              | Body could not be parsed |\n| 400  | `playlist_empty`            | POST /rooms/playlist with no urls |\n| 400  | `too_many_items`            | Playlist > 50 items (see `max`) |\n| 400  | `room_id_required`          | Path is /api/v1/rooms/ with empty id |\n| 400  | `invalid_room_id`           | Room id failed format check |\n| 401  | `api_key_required`          | Missing / unknown / revoked X-API-Key |\n| 403  | `forbidden`                 | Authenticated but not room owner |\n| 403  | `plan_limit`                | Active room cap reached on caller's plan |\n| 403  | `rtmp_not_allowed`          | isRtmp=true but plan lacks RTMP |\n| 403  | `embed_not_allowed`         | allowedDomains exceeds plan cap |\n| 404  | `room_not_found`            | Room expired, never existed, or belongs to another tenant |\n| 405  | `method_not_allowed`        | Wrong HTTP verb |\n| 422  | `no_valid_items`            | Every playlist URL failed probe — see items[] for per-line errors |\n| 429  | `rate_limit_exceeded`       | Per-user bucket exhausted |\n| 500  | `playlist_segmenter_failed` | Edge + local segmenter both failed |\n| 500  | `internal`                  | Unexpected error (always with `detail`) |\n\n---\n\n## Webhooks\n\nOutbound webhooks deliver `room.created`, `room.ended`, `stream.started`, `stream.offline`, and `vod.ready` events. Configure URLs from **Dashboard → Developer → Webhooks**; the dashboard mints a per-hook secret used to sign every delivery.\n\nEvery request carries:\n- `X-SPDN-Event` — event name (e.g. `room.created`)\n- `X-SPDN-Signature` — `sha256=<hex>` HMAC over the raw JSON body, keyed by the per-hook secret\n- `X-SPDN-Delivery` — `hook-<id>-attempt-<n>` — stable id for idempotent consumers\n\nRetries: up to 3 attempts with exponential backoff (2s, 4s, 8s). Non-2xx responses and transport errors both count as failures. The delivery queue is bounded at 256; under subscriber outage SPDN drops overflow and logs at WARN — the producer (a busy room) never blocks.\n",
    "contact": {
      "name": "SPDN Support",
      "url": "https://spdn.tv/contact",
      "email": "hi@spdn.tv"
    },
    "license": {
      "name": "Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://spdn.tv",
      "description": "Production"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Account",
      "description": "Inspect the caller's user record and the plan capabilities it currently entitles."
    },
    {
      "name": "Rooms",
      "description": "Create and manage single-source rooms — HLS pull, live MPEG-TS, RTMP ingest, single VOD file. Same endpoint handles every type; SPDN auto-detects from the streamUrl suffix and the isRtmp flag."
    },
    {
      "name": "Playlists",
      "description": "Create a multi-file VOD room. SPDN probes every input URL, concatenates the playable ones into one continuous HLS timeline (with EXT-X-DISCONTINUITY at file boundaries), and can optionally loop the result forever."
    },
    {
      "name": "Stats",
      "description": "Per-room metrics for billing, observability, and dashboards. Polling at 1–5 second intervals is well within the rate limit."
    },
    {
      "name": "Embedding",
      "description": "How to drop a SPDN room into a third-party site. The /embed/{id} surface is HTML (not JSON); this section describes the URL contract and the allowedDomains gating model."
    },
    {
      "name": "Webhooks",
      "description": "Reference material — outbound event shapes and signature scheme. Configure delivery URLs from the dashboard; SPDN POSTs there when events occur."
    }
  ],
  "paths": {
    "/api/v1/me": {
      "get": {
        "tags": ["Account"],
        "summary": "Current user + plan entitlements",
        "description": "Returns the user record associated with the API key, plus the resolved plan catalogue entry. Use this to surface the customer's name/email in your dashboard and to check capability flags (e.g. `plan.rtmpAllowed`) before offering RTMP-only UI affordances.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MeResponse" },
                "example": {
                  "user": {
                    "id": 42,
                    "username": "alice",
                    "email": "alice@example.com",
                    "plan": "streamer",
                    "isAdmin": false
                  },
                  "plan": {
                    "id": "streamer",
                    "name": "Streamer",
                    "maxRooms": 10,
                    "rtmpAllowed": true,
                    "iframeDomains": 10,
                    "customLogo": true,
                    "apiAccess": true,
                    "vodDays": 30,
                    "analyticsLevel": "detailed"
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/rooms": {
      "get": {
        "tags": ["Rooms"],
        "summary": "List the caller's active rooms",
        "description": "Returns every in-memory room where `createdBy` matches the API key's owner. Historical (already-ended) rooms are **not** included — those live in the SQLite `room_history` table which is not exposed via v1.\n\nAdmin keys see all active rooms across all tenants.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "rooms": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/RoomStats" }
                    },
                    "count": { "type": "integer", "example": 1 }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["Rooms"],
        "summary": "Create a single-source room",
        "description": "Creates a new room around one streaming source. SPDN auto-detects the mode from the inputs:\n\n- `streamUrl` ending in `.m3u8` / `.m3u` → **Live HLS** — edge proxies segments\n- `streamUrl` ending in `.ts` → **Live MPEG-TS** — edge ffmpeg-ingests over one connection and re-segments\n- `streamUrl` ending in `.mp4` / `.mkv` / other video file → **VOD** — edge segments the file, room ends naturally on finish\n- `isRtmp: true` (streamUrl ignored) → **RTMP** — server mints a key and ingest URL for your encoder\n\nPlan gates fire **before** the room is created: `plan_limit` (room cap), `rtmp_not_allowed` (no RTMP entitlement), `embed_not_allowed` (too many domains).\n\nFor multi-file VOD, use `POST /api/v1/rooms/playlist` instead.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateRoomRequest" },
              "examples": {
                "live-hls": {
                  "summary": "Live HLS (.m3u8 source)",
                  "value": {
                    "streamUrl": "https://example.com/live/master.m3u8",
                    "name": "Sunday Service",
                    "language": "en",
                    "chatEnabled": true,
                    "allowedDomains": ["mychurch.org"]
                  }
                },
                "live-ts": {
                  "summary": "Live MPEG-TS (Xtream panel)",
                  "value": {
                    "streamUrl": "http://panel.example.com/live/user/pass/12345.ts",
                    "name": "Channel 1",
                    "isPrivate": true,
                    "password": "viewer-pin"
                  }
                },
                "live-dash": {
                  "summary": "Live MPEG-DASH (.mpd manifest)",
                  "value": {
                    "streamUrl": "https://cdn.example.com/live/channel/manifest.mpd",
                    "name": "DASH Channel",
                    "language": "en"
                  }
                },
                "vod-mp4": {
                  "summary": "Single VOD file",
                  "value": {
                    "streamUrl": "https://cdn.example.com/movies/launch.mp4",
                    "name": "Launch Recap"
                  }
                },
                "rtmp": {
                  "summary": "RTMP ingest (push from OBS)",
                  "value": {
                    "isRtmp": true,
                    "name": "Tonight's Stream",
                    "allowedDomains": ["mysite.com"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Room created",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CreateRoomResponse" },
                "examples": {
                  "hls-or-vod": {
                    "summary": "HLS / TS / VOD response",
                    "value": {
                      "roomId": "AB3CDE",
                      "name": "Sunday Service",
                      "createdAt": "2026-05-21T08:35:09Z",
                      "isPrivate": false,
                      "hasPassword": false,
                      "creatorToken": "f2a9b7c10d3e4f5a6b7c8d9e0f1a2b3c"
                    }
                  },
                  "rtmp": {
                    "summary": "RTMP response carries publish credentials",
                    "value": {
                      "roomId": "XY7ZAB",
                      "name": "Tonight's Stream",
                      "createdAt": "2026-05-21T08:35:09Z",
                      "isPrivate": false,
                      "hasPassword": false,
                      "creatorToken": "a1b2c3d4e5f6071829304a5b6c7d8e9f",
                      "isRtmp": true,
                      "streamKey": "sk_9b3f1e2d8c4a5b6f7e8a9b0c1d2e3f4a",
                      "rtmpIngestUrl": "rtmp://rtmp.spdn.tv/live"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, missing streamUrl, or invalid JSON",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "invalid_json" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Plan gate denied the request",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "examples": {
                  "plan-limit": {
                    "summary": "Active room cap reached",
                    "value": {
                      "error": "plan_limit",
                      "reason": "Plan limit reached: you already have 1 active room",
                      "plan": "free",
                      "maxRooms": 1,
                      "upgrade": "/dashboard#plan"
                    }
                  },
                  "rtmp-not-allowed": {
                    "summary": "Plan lacks RTMP entitlement",
                    "value": {
                      "error": "rtmp_not_allowed",
                      "reason": "RTMP ingest requires a Streamer or Business plan",
                      "plan": "creator",
                      "upgrade": "/dashboard#plan"
                    }
                  },
                  "embed-not-allowed": {
                    "summary": "Too many allowedDomains for plan",
                    "value": {
                      "error": "embed_not_allowed",
                      "reason": "Your plan allows up to 1 embed domain (you requested 5)",
                      "upgrade": "/dashboard#plan"
                    }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/rooms/playlist": {
      "post": {
        "tags": ["Playlists"],
        "summary": "Create a multi-file VOD playlist room",
        "description": "Concatenates several VOD source URLs into one continuous HLS timeline served from a single room. The edge runs ONE ffmpeg concat-demuxer job over the playable items; `#EXT-X-DISCONTINUITY` markers are emitted at file boundaries so mixed codecs / resolutions work.\n\n### Probe behaviour\n\nEvery input URL is probed server-side (ffprobe, 20s timeout per item, max 4 in parallel). Items that fail probe are **skipped, not fatal** — only the playable H.264 + AAC ones go into the playlist. The response carries `skippedCount` and a per-line `items[]` array so your UI can show `✓` / `✗` per row.\n\nIf *every* URL fails probe, the call returns `422 no_valid_items` with the same `items[]` payload so you can surface the per-line errors.\n\n### Loop semantics\n\n- `loop: false` (default) — playlist plays through once; the reaper terminates the room when the last file finishes (room duration = sum of item durations).\n- `loop: true` — infinite playlist; the concat job restarts from item 0 when it reaches the end. Reaper never expires the room on duration.\n\n### Limits\n\n- Maximum 50 items per playlist (`too_many_items` with `max: 50`).\n- Each item ≤ 2 hours (enforced by probe).\n- Items must be H.264 + AAC; other codecs are skipped by probe.\n\n### Ads\n\nWhen an admin-uploaded SPDN ad is active, it is interleaved as a pre-roll plus one ad before every film. Paid viewers see an SPDN card overlay for the ad duration instead of the actual ad video. This is transparent to API callers — `playlistCount` reports only the films you submitted.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreatePlaylistRequest" },
              "examples": {
                "simple": {
                  "summary": "Three films, no loop",
                  "value": {
                    "urls": [
                      "https://cdn.example.com/films/intro.mp4",
                      "https://cdn.example.com/films/main.mp4",
                      "https://cdn.example.com/films/credits.mp4"
                    ],
                    "name": "Festival Block A",
                    "language": "en",
                    "allowedDomains": ["festival.example.com"]
                  }
                },
                "looping-channel": {
                  "summary": "Infinite-loop 24/7 channel",
                  "value": {
                    "urls": [
                      "https://cdn.example.com/promos/spot1.mp4",
                      "https://cdn.example.com/promos/spot2.mp4",
                      "https://cdn.example.com/promos/spot3.mp4"
                    ],
                    "loop": true,
                    "name": "Promo Channel"
                  }
                },
                "private-passworded": {
                  "summary": "Private + password",
                  "value": {
                    "urls": [
                      "https://cdn.example.com/internal/q1.mkv",
                      "https://cdn.example.com/internal/q2.mkv"
                    ],
                    "name": "Quarterly Reviews",
                    "isPrivate": true,
                    "password": "quarterly-2026"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Playlist created",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CreatePlaylistResponse" },
                "example": {
                  "roomId": "PL7XYZ",
                  "name": "Festival Block A",
                  "createdAt": "2026-05-21T08:35:09Z",
                  "isPrivate": false,
                  "hasPassword": false,
                  "creatorToken": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
                  "playlistCount": 3,
                  "skippedCount": 0,
                  "loop": false,
                  "items": [
                    {
                      "url": "https://cdn.example.com/films/intro.mp4",
                      "ok": true,
                      "duration": 120.5,
                      "videoCodec": "h264",
                      "audioCodec": "aac",
                      "width": 1920,
                      "height": 1080
                    },
                    {
                      "url": "https://cdn.example.com/films/main.mp4",
                      "ok": true,
                      "duration": 3540.0,
                      "videoCodec": "h264",
                      "audioCodec": "aac",
                      "width": 1920,
                      "height": 1080
                    },
                    {
                      "url": "https://cdn.example.com/films/credits.mp4",
                      "ok": true,
                      "duration": 45.0,
                      "videoCodec": "h264",
                      "audioCodec": "aac",
                      "width": 1920,
                      "height": 1080
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Invalid body (empty urls, too many items, malformed JSON)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "examples": {
                  "empty": {
                    "summary": "No URLs provided",
                    "value": { "error": "playlist_empty" }
                  },
                  "too-many": {
                    "summary": "More than 50 items",
                    "value": { "error": "too_many_items", "max": 50 }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Plan limit or embed gate denied",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": {
                  "error": "plan_limit",
                  "reason": "Plan limit reached: you already have 1 active room",
                  "plan": "free",
                  "maxRooms": 1,
                  "upgrade": "/dashboard#plan"
                }
              }
            }
          },
          "422": {
            "description": "Every URL failed probe — items[] shows per-line reasons",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error":   { "type": "string", "example": "no_valid_items" },
                    "message": { "type": "string" },
                    "items": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/PlaylistProbeItem" }
                    }
                  }
                },
                "example": {
                  "error": "no_valid_items",
                  "message": "Hiçbir URL oynatılabilir değil (H.264 + AAC gerekli).",
                  "items": [
                    {
                      "url": "https://broken.example.com/a.mp4",
                      "ok": false,
                      "error": "probe failed: dial tcp: lookup broken.example.com: no such host"
                    },
                    {
                      "url": "https://example.com/h265.mkv",
                      "ok": false,
                      "error": "unsupported video codec: hevc (need h264)"
                    }
                  ]
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": {
            "description": "Edge placement AND local segmenter both failed",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": {
                  "error": "playlist_segmenter_failed",
                  "detail": "ffmpeg exited unexpectedly: exit status 1"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/rooms/{id}": {
      "get": {
        "tags": ["Rooms"],
        "summary": "Full stats + metadata for one room",
        "description": "Returns the complete `RoomStats` projection — byte counters, peak viewer count, timestamps, room type flags. Cookie-only fields (chat history, mute lists) are intentionally not exposed via v1.\n\nReturns 404 (not 403) for rooms that don't exist *or* that belong to other tenants, to prevent enumeration. Admin keys see all rooms.",
        "parameters": [{ "$ref": "#/components/parameters/RoomID" }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RoomStats" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "patch": {
        "tags": ["Rooms"],
        "summary": "Partial-update mutable settings",
        "description": "Patches a room in place. Every field is optional; only the keys present in the body are mutated. Other state stays exactly as it was.\n\n**Immutable** (delete-and-recreate to change): `streamUrl` on RTMP rooms (it's derived from the stream key), `streamKey` itself, `watermarkMode`, ownership, the `isRtmp` flag. For HLS-pull rooms `streamUrl` IS mutable — swap upstream origins without dropping viewers.\n\nEmbed mutations re-run the plan gate: a downgraded customer can hit `embed_not_allowed` here even though the room already existed.",
        "parameters": [{ "$ref": "#/components/parameters/RoomID" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/UpdateRoomRequest" },
              "example": {
                "name": "Sunday Service (rebranded)",
                "allowedDomains": ["mychurch.org", "*.mychurch.org"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated — `updatedFields` lists what actually changed",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UpdateRoomResponse" }
              }
            }
          },
          "400": {
            "description": "Invalid JSON or rejected field",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["Rooms"],
        "summary": "Remove a room",
        "description": "Hard-removes a room. New `/api/room/{id}` joins receive 404 immediately. Active WebSocket peers keep their connections until they disconnect naturally — the v1 surface never force-closes sockets (the chat / signaling hot path is intentionally untouchable from here).\n\nDeletion is idempotent: a 404 means the room already expired or was never owned by this key.\n\nThe room's `room_history` row persists with a final `peakViewers` count — that aggregate is returned in the response so audit pipelines can capture it.",
        "parameters": [{ "$ref": "#/components/parameters/RoomID" }],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["deleted", "roomId"],
                  "properties": {
                    "deleted":     { "type": "boolean", "example": true },
                    "roomId":      { "type": "string", "example": "AB3CDE" },
                    "peakViewers": { "type": "integer", "description": "High-water concurrent viewer count observed for this room." }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/rooms/{id}/stats": {
      "get": {
        "tags": ["Stats"],
        "summary": "Compact stats projection",
        "description": "Trimmed projection of `RoomStats` suitable for high-frequency polling — billing pipelines, observability sidecars, status dashboards. Same auth, ownership and rate-limit contract as the full endpoint.\n\nFields:\n- `currentViewers` / `peakViewers` — concurrent + high-water mark\n- `p2pBytes` / `originBytes` — bytes served from the mesh vs. the edge\n- `p2pRatio` — `p2pBytes / (p2pBytes + originBytes)` (0 when no traffic yet)\n- `startedAt` / `lastActivity` — timestamps in RFC 3339",
        "parameters": [{ "$ref": "#/components/parameters/RoomID" }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RoomStatsCompact" },
                "example": {
                  "roomId": "AB3CDE",
                  "currentViewers": 142,
                  "peakViewers": 318,
                  "p2pBytes": 9876543210,
                  "originBytes": 2345678901,
                  "p2pRatio": 0.808,
                  "startedAt": "2026-05-21T08:35:09Z",
                  "lastActivity": "2026-05-21T09:12:44Z"
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/embed/{id}": {
      "get": {
        "tags": ["Embedding"],
        "summary": "Embeddable iframe (HTML)",
        "security": [],
        "description": "Returns the SPDN player HTML for an iframe. **Not** a JSON API — this is an HTML surface meant to be loaded inside an `<iframe>` tag on third-party pages.\n\n```html\n<iframe src=\"https://spdn.tv/embed/AB3CDE\"\n        width=\"960\" height=\"540\"\n        frameborder=\"0\" allowfullscreen></iframe>\n```\n\nAccess is gated by the room's `allowedDomains` (set via POST / PATCH on the room). Enforcement is two-layered:\n\n1. **Referer check** — request returns 403 if `Referer` does not match any allowed domain (or its `*.allowed.com` wildcard).\n2. **CSP frame-ancestors** — response sets `Content-Security-Policy: frame-ancestors <list>` so browsers refuse to render the iframe on non-allowed pages even if Referer is spoofed.\n\nPrivate rooms cannot be embedded (`isPrivate: true` → 403 `Private stream`). Rooms with empty `allowedDomains` are equally blocked (403 `Embed disabled`).",
        "parameters": [{ "$ref": "#/components/parameters/RoomID" }],
        "responses": {
          "200": {
            "description": "Player HTML",
            "content": {
              "text/html": {
                "schema": { "type": "string", "format": "binary" }
              }
            }
          },
          "400": { "description": "Malformed room ID in path" },
          "403": { "description": "Private room, embed disabled, or Referer not in allowedDomains" },
          "404": { "description": "Room expired or never existed" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Prefixed `spdn_` followed by 32 hex characters. Mint from **Dashboard → Developer**; the raw value is only shown once. Revoked keys return 401 even before expiry."
      }
    },
    "parameters": {
      "RoomID": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^[A-Z0-9]{6}$"
        },
        "description": "6-character uppercase room ID (letters + digits, no I/O/0/1 — visually distinct).",
        "example": "AB3CDE"
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid API key",
        "headers": {
          "WWW-Authenticate": {
            "schema": { "type": "string", "example": "APIKey realm=\"spdn.tv\"" }
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "api_key_required" }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated but not permitted (not the room owner; not an admin)",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "forbidden" }
          }
        }
      },
      "NotFound": {
        "description": "Resource does not exist — or belongs to another tenant (404 in place of 403 prevents enumeration)",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "room_not_found" }
          }
        }
      },
      "RateLimited": {
        "description": "Per-user bucket exhausted. Retry after the indicated window.",
        "headers": {
          "Retry-After": {
            "schema": { "type": "integer", "example": 60 },
            "description": "Seconds until the bucket refills to a useful level."
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "rate_limit_exceeded" }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error":   { "type": "string", "description": "Stable machine-readable token. Branch on this, never on `message`." },
          "message": { "type": "string", "description": "Human-friendly detail (optional, locale-dependent)." },
          "reason":  { "type": "string", "description": "Additional context for plan / embed / RTMP rejections." },
          "plan":    { "type": "string", "description": "Caller's current plan id, when relevant." },
          "maxRooms":{ "type": "integer", "description": "Room cap on the caller's plan, when relevant." },
          "upgrade": { "type": "string", "description": "Dashboard URL the customer can visit to upgrade." },
          "max":     { "type": "integer", "description": "Cap that was exceeded (e.g. playlist size)." },
          "detail":  { "type": "string", "description": "Low-level error string for 500-class responses." },
          "items":   {
            "type": "array",
            "description": "Per-item probe results — only present on playlist 422 responses.",
            "items": { "$ref": "#/components/schemas/PlaylistProbeItem" }
          }
        }
      },
      "Plan": {
        "type": "object",
        "description": "Capability flags resolved from the user's current plan.",
        "properties": {
          "id":             { "type": "string", "enum": ["free", "creator", "streamer", "business"] },
          "name":           { "type": "string" },
          "maxRooms":       { "type": "integer", "description": "-1 means unlimited." },
          "rtmpAllowed":    { "type": "boolean", "description": "Whether `isRtmp: true` is permitted on this plan." },
          "iframeDomains":  { "type": "integer", "description": "Max entries in `allowedDomains`. -1 = unlimited; 0 = embed disabled." },
          "customLogo":     { "type": "boolean", "description": "Whether the creator's logo replaces SPDN's watermark." },
          "apiAccess":      { "type": "boolean", "description": "Whether the user may mint v1 API keys at all." },
          "vodDays":        { "type": "integer", "description": "How many days RTMP recordings are retained. 0 = no recording." },
          "analyticsLevel": { "type": "string", "enum": ["basic", "detailed", "full"] }
        }
      },
      "User": {
        "type": "object",
        "properties": {
          "id":       { "type": "integer", "format": "int64" },
          "username": { "type": "string" },
          "email":    { "type": "string", "format": "email" },
          "plan":     { "type": "string", "description": "Plan id; see Plan.id enum." },
          "isAdmin":  { "type": "boolean" }
        }
      },
      "MeResponse": {
        "type": "object",
        "properties": {
          "user": { "$ref": "#/components/schemas/User" },
          "plan": { "$ref": "#/components/schemas/Plan" }
        }
      },
      "RoomStats": {
        "type": "object",
        "description": "Full projection — metadata, byte counters, timestamps.",
        "properties": {
          "roomId":           { "type": "string", "example": "AB3CDE" },
          "name":             { "type": "string" },
          "isRtmp":           { "type": "boolean" },
          "isPrivate":        { "type": "boolean" },
          "chatEnabled":      { "type": "boolean" },
          "createdAt":        { "type": "string", "format": "date-time" },
          "currentViewers":   { "type": "integer", "description": "Concurrent viewer count at the moment of the read." },
          "peakViewers":      { "type": "integer", "description": "High-water mark observed for this room." },
          "p2pBytes":         { "type": "integer", "format": "int64", "description": "Bytes served peer-to-peer via the mesh." },
          "originBytes":      { "type": "integer", "format": "int64", "description": "Bytes served from the edge (origin) HTTP path." },
          "p2pRatio":         { "type": "number", "format": "double", "description": "p2pBytes / (p2pBytes + originBytes). 0 when no traffic yet." },
          "metricsStartedAt": { "type": "string", "format": "date-time" },
          "lastActivity":     { "type": "string", "format": "date-time" }
        }
      },
      "RoomStatsCompact": {
        "type": "object",
        "description": "Trimmed projection for polling / cost aggregation pipelines.",
        "properties": {
          "roomId":         { "type": "string" },
          "currentViewers": { "type": "integer" },
          "peakViewers":    { "type": "integer" },
          "p2pBytes":       { "type": "integer", "format": "int64" },
          "originBytes":    { "type": "integer", "format": "int64" },
          "p2pRatio":       { "type": "number" },
          "startedAt":      { "type": "string", "format": "date-time" },
          "lastActivity":   { "type": "string", "format": "date-time" }
        }
      },
      "CreateRoomRequest": {
        "type": "object",
        "properties": {
          "streamUrl": {
            "type": "string",
            "format": "uri",
            "description": "Source URL. SPDN auto-detects the room type from the suffix:\n\n- `.m3u8` / `.m3u` → live HLS (edge proxies segments)\n- `.ts` → live MPEG-TS (edge ffmpeg-ingests over one connection, re-segments)\n- `.mpd` → live MPEG-DASH (edge ffmpeg demuxes DASH, remuxes to HLS; non-DRM H.264 + AAC streams only)\n- `.mp4` / `.mkv` / other video file → VOD (edge segments the file)\n\nRequired in all non-RTMP modes. Ignored when `isRtmp: true` — the server generates an internal URL pointing at the local nginx-rtmp HLS output."
          },
          "name": {
            "type": "string",
            "maxLength": 80,
            "example": "Release Party"
          },
          "description": {
            "type": "string",
            "maxLength": 50,
            "description": "Short tagline; truncated server-side when longer."
          },
          "maxViewers": {
            "type": "integer",
            "minimum": 0,
            "description": "Hard cap on concurrent viewers. 0 means no cap (subject to plan limits)."
          },
          "language": {
            "type": "string",
            "example": "en",
            "description": "Free-form locale hint (ISO 639-1 / country codes). Used for public-listing filters."
          },
          "chatEnabled": {
            "type": "boolean",
            "description": "If false, the viewer-side chat UI is hidden and incoming messages are dropped."
          },
          "isPrivate": {
            "type": "boolean",
            "description": "Private rooms are excluded from /api/rooms/public listings and cannot be embedded."
          },
          "password": {
            "type": "string",
            "description": "Optional; if set, joiners must supply it on the WebSocket join payload."
          },
          "allowedDomains": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Domains allowed to embed this room via /embed/{id}. Enforced via Referer check + CSP frame-ancestors. Wildcards (`*.example.com`) accepted. Cap depends on plan."
          },
          "isRtmp": {
            "type": "boolean",
            "description": "When true, the server mints a stream key and RTMP ingest URL; `streamUrl` is ignored. Requires `plan.rtmpAllowed`. Defaults to false."
          }
        }
      },
      "CreateRoomResponse": {
        "type": "object",
        "required": ["roomId", "name", "createdAt"],
        "properties": {
          "roomId":      { "type": "string", "example": "AB3CDE" },
          "name":        { "type": "string" },
          "createdAt":   { "type": "string", "format": "date-time" },
          "isPrivate":   { "type": "boolean" },
          "hasPassword": { "type": "boolean" },
          "creatorToken": {
            "type": "string",
            "description": "32-hex secret. The WebSocket join payload must carry this to claim `isCreator: true` (chat moderation, end-stream privileges). Returned exactly once — store it client-side (sessionStorage) and never log it. Without it, the WS caller is treated as a plain viewer."
          },
          "isRtmp": {
            "type": "boolean",
            "description": "True when the response carries RTMP publish credentials. Omitted or false for non-RTMP rooms."
          },
          "streamKey": {
            "type": "string",
            "description": "Raw RTMP stream key. Only present on RTMP-mode creates. Shown ONCE; store securely and never log. The hub keeps the full key in memory but only persists an 8-hex prefix in vod_sessions (K5 defence)."
          },
          "rtmpIngestUrl": {
            "type": "string",
            "example": "rtmp://rtmp.spdn.tv/live",
            "description": "RTMP server URL. In OBS-style clients, plug into the **Server** field; put `streamKey` into the **Stream Key** field."
          }
        }
      },
      "UpdateRoomRequest": {
        "type": "object",
        "description": "Partial update. Every field is optional; omitted fields leave the stored room untouched. To clear a value, send its zero form (empty string for text, empty array for allowedDomains).",
        "properties": {
          "name":           { "type": "string", "maxLength": 80 },
          "description":    { "type": "string", "maxLength": 50, "description": "Truncated to 50 characters server-side." },
          "maxViewers":     { "type": "integer", "minimum": 0, "description": "0 means unlimited." },
          "language":       { "type": "string" },
          "chatEnabled":    { "type": "boolean" },
          "isPrivate":      { "type": "boolean" },
          "password":       { "type": "string", "description": "New password (empty string clears)." },
          "allowedDomains": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Re-validated against the caller's plan on every request. Empty array disables embed."
          },
          "streamUrl": {
            "type": "string",
            "format": "uri",
            "description": "HLS-pull rooms only — swap upstream origin without re-creating the room. Rejected on RTMP rooms (streamUrl is derived from the stream key)."
          }
        }
      },
      "UpdateRoomResponse": {
        "type": "object",
        "required": ["roomId", "name", "updatedFields", "isPrivate", "hasPassword"],
        "properties": {
          "roomId":      { "type": "string" },
          "name":        { "type": "string" },
          "updatedFields": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Names of the fields actually mutated. Useful for audit logs and for the caller to verify their payload took effect."
          },
          "isPrivate":   { "type": "boolean" },
          "hasPassword": { "type": "boolean" }
        },
        "example": {
          "roomId": "AB3CDE",
          "name": "Release Party (updated)",
          "updatedFields": ["name", "description", "allowedDomains"],
          "isPrivate": false,
          "hasPassword": false
        }
      },
      "CreatePlaylistRequest": {
        "type": "object",
        "required": ["urls"],
        "properties": {
          "urls": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "items": { "type": "string", "format": "uri" },
            "description": "Ordered VOD source URLs (mp4 / mkv / etc.). Each is probed server-side; only H.264 + AAC items survive into the playlist. Cap: 50 items."
          },
          "loop": {
            "type": "boolean",
            "description": "When true, the playlist repeats forever (infinite room). When false (default), the reaper terminates the room after the last item finishes."
          },
          "name":           { "type": "string", "maxLength": 80 },
          "description":    { "type": "string", "maxLength": 50 },
          "maxViewers":     { "type": "integer", "minimum": 0 },
          "language":       { "type": "string" },
          "chatEnabled":    { "type": "boolean" },
          "isPrivate":      { "type": "boolean" },
          "password":       { "type": "string" },
          "allowedDomains": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Embed allowlist. Same semantics as POST /api/v1/rooms — Referer check + CSP frame-ancestors."
          }
        }
      },
      "CreatePlaylistResponse": {
        "type": "object",
        "required": ["roomId", "name", "createdAt", "playlistCount", "items", "loop"],
        "properties": {
          "roomId":        { "type": "string", "example": "PL7XYZ" },
          "name":          { "type": "string" },
          "createdAt":     { "type": "string", "format": "date-time" },
          "isPrivate":     { "type": "boolean" },
          "hasPassword":   { "type": "boolean" },
          "creatorToken":  { "type": "string", "description": "Same semantics as CreateRoomResponse.creatorToken." },
          "playlistCount": { "type": "integer", "description": "Number of items that probed clean and were added to the playlist." },
          "skippedCount":  { "type": "integer", "description": "Number of input URLs that failed probe and were skipped." },
          "loop":          { "type": "boolean", "description": "Echoes the request — true if the playlist loops forever." },
          "items": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/PlaylistProbeItem" },
            "description": "Per-item probe results, one entry per *input* URL in input order. UIs use this to render ✓/✗ feedback per row."
          }
        }
      },
      "PlaylistProbeItem": {
        "type": "object",
        "required": ["url", "ok"],
        "properties": {
          "url":        { "type": "string", "format": "uri" },
          "ok":         { "type": "boolean", "description": "True when the URL probed clean and is included in the playlist." },
          "error":      { "type": "string", "description": "When ok=false, human-readable reason (codec mismatch, unreachable, etc.)." },
          "duration":   { "type": "number", "format": "double", "description": "Seconds." },
          "videoCodec": { "type": "string", "example": "h264" },
          "audioCodec": { "type": "string", "example": "aac" },
          "width":      { "type": "integer" },
          "height":     { "type": "integer" }
        }
      },
      "WebhookEnvelope": {
        "type": "object",
        "description": "Every outbound webhook wraps its payload in this envelope. The shape of `data` depends on `event`.",
        "required": ["event", "timestamp", "data"],
        "properties": {
          "event":     { "type": "string", "enum": ["room.created", "room.ended", "stream.started", "stream.offline", "vod.ready"] },
          "timestamp": { "type": "string", "format": "date-time" },
          "data":      { "type": "object" }
        }
      },
      "Event_RoomCreated": {
        "type": "object",
        "properties": {
          "id":        { "type": "string", "example": "AB3CDE" },
          "name":      { "type": "string" },
          "createdBy": { "type": "integer", "format": "int64", "description": "User ID. 0 for guest-created rooms." }
        }
      },
      "Event_RoomEnded": {
        "type": "object",
        "properties": {
          "id":          { "type": "string" },
          "peakViewers": { "type": "integer" }
        }
      },
      "Event_StreamStarted": {
        "type": "object",
        "description": "Fires on RTMP on_publish success. HLS-pull, MPEG-TS, VOD and playlist rooms do NOT emit this — they only emit room.created.",
        "properties": {
          "roomId": { "type": "string" },
          "userId": { "type": "integer", "format": "int64" },
          "isRtmp": { "type": "boolean", "example": true }
        }
      },
      "Event_StreamOffline": {
        "type": "object",
        "description": "Fires on RTMP on_publish_done. Arrives before room.ended when the creator disconnects their encoder.",
        "properties": {
          "roomId": { "type": "string" },
          "userId": { "type": "integer", "format": "int64" }
        }
      },
      "Event_VODReady": {
        "type": "object",
        "description": "Fires after the recording row transitions to status=\"stored\" (post-rename, post-size-calc). Only RTMP rooms with plan.vodDays > 0 produce this event.",
        "properties": {
          "sessionId":       { "type": "integer", "format": "int64" },
          "roomId":          { "type": "string" },
          "userId":          { "type": "integer", "format": "int64" },
          "durationSeconds": { "type": "integer", "format": "int64" },
          "sizeBytes":       { "type": "integer", "format": "int64" },
          "expiresAt":       { "type": "string", "format": "date-time" },
          "isPublic":        { "type": "boolean" },
          "playbackUrl":     { "type": "string", "example": "/vod/123/index.m3u8" }
        }
      }
    }
  },
  "x-webhooks": {
    "description": "Outbound webhooks are configured from the dashboard Developer panel. Every request carries HMAC-SHA256 authenticity headers: `X-SPDN-Signature: sha256=<hex>` over the raw JSON body, keyed by your per-hook secret. `X-SPDN-Event` surfaces the event name; `X-SPDN-Delivery` is a unique id (`hook-<id>-attempt-<n>`) so idempotent consumers can deduplicate retries.\n\nRetries: up to 3 attempts, exponential backoff (2s, 4s, 8s). Non-2xx responses and transport errors both count as failures. The delivery queue is bounded at 256; under a subscriber outage SPDN drops overflow and logs at WARN (the producer, e.g. a busy room, never blocks)."
  }
}
