# Floatplane unofficial API — research report for a Jellyfin plugin (as of 2026-10-25) Legend: **[V]** = verified from docs/spec/source code or a live fetch today; **[I]** = inferred / not directly verified. Local copies of everything I read are in `/tmp/claude-1000/-home-brasse/8a10aa19-62bd-490c-b689-b51638220e46/scratchpad/src/` (e.g. `spec_full.json`, `spec_dump.txt`, `oidc.json`, `fpts_*.ts`, `fpdl_*.ts`, `hydra_*.brs`, `wf_*.swift`, `floaty_*.dart`, `fn_*.kt`, `fn_ios_*.swift`, `ytdlp_floatplane.py`, `bz_*.mjs`). ## 0. Executive summary — what changed and what it means for a Jellyfin plugin 1. **Username/password login is legacy.** Every actively maintained third-party client (floatplane-downloader 5.19.x, Hydravion Roku, Wasserflug tvOS, Floaty, FloatNative) has migrated in 2025–2026 to **OAuth 2.0 / OIDC on a Keycloak server at `https://auth.floatplane.com/realms/floatplane`**, using the **device-code grant** (TV/CLI) or auth-code+PKCE (mobile/desktop), and then call the REST API with `Authorization: Bearer ` instead of the `sails.sid` cookie. **[V]** (sources: floatplaneAPI.ts `src/index.ts`, Hydravion `oauthDeviceTask.brs`, Wasserflug `Wasserflug_tvOSApp.swift`, Floaty `oauth2_service.dart`, FloatNative `OAuthApi.kt`, live OIDC discovery doc). 2. **You need an OAuth `client_id` registered by Floatplane.** The `floatplane` npm README says `clientId: "example-client", // Has to be registered on FloatPlane`. Known registered public clients: `hydravion`, `wasserflug`, `floatplane-downloader`, `floaty`, `floatnative`, and Floatplane's own `fp-tv-app`. There is no public self-service registration page; the maintainers evidently obtained client IDs from Floatplane staff (the community Discord `https://discord.gg/4xKDGz5M5B` is where Hydravion/Wasserflug devs hang out). **[V]** for the IDs, **[I]** for the "ask staff" process. Using another app's client ID would work technically but is not yours to use; the bmlzootown canary code explicitly says "Do not invent alternate clients". 3. **Some clients require DPoP (RFC 9449).** `fp-tv-app` and `floatnative` are configured with `usesExtendedSecurity` → PKCE S256 on the device-auth request **and** a DPoP proof (ES256/P-256) on every token-endpoint and REST request (`Authorization: DPoP `). `hydravion`, `wasserflug`, `floatplane-downloader`, `floaty` use plain `Bearer` without DPoP. So whether your plugin needs DPoP depends on how Floatplane configures your client. **[V]** 4. **The CDN/HLS side needs auth too (for VOD).** Hydravion sets `Authorization: Bearer` on the Roku video node for all playlist/segment/key requests; Floaty passes `Authorization` + `Referer`/`Origin` headers to mpv; Wasserflug intercepts AVPlayer loads and adds Bearer; FloatNative fetches the **m3u8 manifest and the AES-128 `#EXT-X-KEY` with DPoP**, but rewrites **segment** URIs to plain absolute https ("the backend is designed around this split: keys require session-bound auth, segments don't"). Live (IVS) streams carry the token only in the URL and are played natively without headers. **[V]** For Jellyfin this means you cannot just hand the HLS URL to the client; the plugin must proxy the playlist+key (or feed ffmpeg `-headers`), see §6.6. 5. **`/api/v3/delivery/info` is rate-limited hard** (community consensus ≈ 2 requests/minute; Floaty budgets 2 per 5 min; 429 + `Retry-After`, Cloudflare error 1015). Cache delivery responses per attachment until the embedded token expires. **[V]** 6. The community OpenAPI spec (jamamp/FloatplaneAPI, spec version `3.10.0-c`, frontend tracked to `4.0.13`) **lags the live site** (frontend is `4.5.25` today) and still documents only `CookieAuth`; the bmlzootown fork README states "OpenAPI may still list only CookieAuth — explorer REST auth is Bearer ahead of that docs lag" and that `sails.sid` is "chat/Socket.IO-only". **[V]** --- ## 1. Community API documentation ### 1.1 Repositories and docs sites - Spec repo (renamed): `https://github.com/jamamp/FloatplaneAPI` (formerly `Jman012/FloatplaneAPI`; old URL redirects). Files: `src/floatplane-openapi-specification.json` (full, 606 KB, OpenAPI 3.0.3, title "Floatplane REST API", version `3.10.0-c`), trimmed spec generated by `tools/trim.js` (drops operations whose description starts with "TODO"), AsyncAPI specs for chat (`chat.floatplane.com`) and frontend sockets. MIT. **[V]** - Rendered docs: `https://jamamp.github.io/FloatplaneAPIDocs/` (Swagger UI, Redoc, RapiDoc, ReSlate, Postman v2.1 collection; trimmed and full variants). The old `jman012.github.io/FloatplaneAPIDocs` now 404s. **[V]** - Trimmed spec download: `https://jamamp.github.io/FloatplaneAPIDocs/floatplane-openapi-specification-trimmed.json` (raw GitHub path for the trimmed file 404s; the full one is `https://raw.githubusercontent.com/jamamp/FloatplaneAPI/main/src/floatplane-openapi-specification.json`). **[V]** - Fork with extras: `https://github.com/bmlzootown/FloatplaneAPI` — static API explorer (`static/explorer/`, PR #6), frontend-change watcher, and an authenticated "API shape canary" (PR #7 Phase 3.0, PR #11/#12 Phase 3.1) that implements the device flow + DPoP in Node (`tools/fp-api-shape-canary/lib/{device-flow,dpop,auth-refresh,constants}.mjs`) and commits measured token lifetimes (`artifacts/api-shape/refresh-lifetime-evidence.json`). **[V]** - README guidance (jamamp): "Please set a custom `User-Agent` header ... that uniquely identifies what app the requests are coming from, and what version ... This is very helpful to the Floatplane team". Integration tests authenticate with env var `sails.sid`. The spec's `info.description` has an "API Flow" section (as of FP 4.0.13): login → `/api/v3/user/subscriptions` → `/api/v3/creator/info` → `/api/v3/content/creator/list` (home) → `/api/v3/content/creator` (creator page) → `/api/v3/content/post` → `/api/v3/content/{video|audio|picture}` → `/api/v3/delivery/info`; livestream via `delivery/info` + `wss://chat.floatplane.com/sails.io/`. **[V]** - Rate limiting text in the spec: "Rate-limited requests will respond with an HTTP 429 ... will also have a `Retry-After` header ... Clients are expected to 1) prevent too many requests ... particularly for the `/api/v2/cdn/delivery` and `/api/v3/delivery/info` endpoints, and 2) properly handle rate-limited responses". **[V]** - LTT forum thread announcing the docs: `https://linustechtips.com/topic/1384044-floatplane-unofficial-floatplane-api-documentation/` (Cloudflare-blocked for fetch tools; not read). **[I]** ### 1.2 Authentication #### A. Legacy cookie login (documented in the spec; still exists server-side; the website bundle still contains `/api/v3/auth/login`, `/api/v3/auth/checkFor2faLogin`, `/api/v3/auth/captcha/info`, `/api/v3/auth/logout`) **[V]** - `POST /api/v2/auth/login` (also `/api/v3/auth/login`) body `{"username","password","captchaToken"?}`; 200 → `{"user": {id, username, profileImage}, "needs2FA": bool}`; sets cookie `sails.sid` (Sails.js session). `captchaToken` description: "The Google Recaptcha v2/v3 token ... On web browsers, this is required. For mobile or TV applications, this is not required only if the User-[Agent …]" (text truncated in my dump; the point is a non-browser User-Agent exempts you). 401 → `ErrorModel {id, errors[{id,name,message?,data?}], message?}`. - `POST /api/v2/auth/checkFor2faLogin` (also v3) body `{"token": ""}` → same login response, completes the session; `POST /api/v2/auth/logout` → `"OK"`. - `GET /api/v3/auth/captcha/info` → `{"v2":{"variants":{"android":{siteKey},"checkbox":{siteKey},"invisible":{siteKey}}},"v3":{"variants":{"invisible":{siteKey}}}}` (Google reCAPTCHA keys). - Cookie lifetime: not documented in the spec; Wasserflug persisted it with the `Expires` from `Set-Cookie` and rewrote the domain to `.floatplane.com`. No verified number. **[I]** - How clients handled captcha: Wasserflug's source comment: "Set custom user agent for network requests. This is particularly required in order to pass the login phase with bypassing the captcha" (UA `"Wasserflug tvOS App version X, CFNetwork"`); the spec's own test UA also ends in `, CFNetwork`; Hydravion's dev wrote (LTT forum, via search snippet) that the Roku store build logs in without a reCAPTCHA token via "a semi-shady workaround that won't be made public". **[V]** for the code comments, **[I]** for how robust that still is. - yt-dlp (master) still uses this path: it requires a `sails.sid` cookie exported from a browser (`_real_initialize` raises login-required otherwise) and sends `Origin`/`Referer: https://www.floatplane.com/`. **[V]** #### B. Current OAuth2/OIDC login (what you should implement) **[V]** Live discovery document fetched today: `https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration` ``` issuer https://auth.floatplane.com/realms/floatplane authorization_endpoint …/protocol/openid-connect/auth token_endpoint …/protocol/openid-connect/token device_authorization_endpoint …/protocol/openid-connect/auth/device revocation_endpoint …/protocol/openid-connect/revoke end_session_endpoint …/protocol/openid-connect/logout grant_types_supported authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code, … (also password, client_credentials, token-exchange, ciba) scopes_supported openid, profile, email, offline_access, videos:watch, videos:download, account:read, account:write, … code_challenge_methods plain, S256 dpop_signing_alg_values ES256, RS256, PS256, EdDSA, … ``` Pre-prod realm/host used by devs: realm `floatplane-pp` with API base `https://pp.floatplane.com` (floatplaneAPI.ts `sanity.ts`, Hydravion `apiConfig.brs`). **[V]** Device-code flow as implemented by Hydravion (`oauthDeviceTask.brs`), floatplaneAPI.ts (`deviceLogin`, via `openid-client`) and the canary: 1. `POST https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth/device`, form `client_id=&scope=openid profile email offline_access` (+ `code_challenge_method=S256&code_challenge=…` when the client has extended security). Response: `{device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval}`. Hydravion shows `floatplane.com/link` as the friendly URL (the website bundle has `linkShortUrl:"floatplane.com/link"`) and renders `verification_uri_complete` as a QR code. 2. Poll `POST …/openid-connect/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=…&device_code=…` (+ `code_verifier`, + `DPoP` header for DPoP clients) every `interval` s; 400 `{"error":"authorization_pending"}` → keep polling; `slow_down` → +5 s; `expired_token`/`access_denied` → abort. 3. Success: `{access_token, expires_in, refresh_token, refresh_expires_in, token_type ("Bearer" or "DPoP"), id_token?, scope, session_state, not-before-policy}`. 4. Refresh: `POST …/token` form `grant_type=refresh_token&client_id=…&refresh_token=…` (DPoP proof again for DPoP clients). Refresh **before** expiry (floatplaneAPI.ts refreshes when <60 s left; Hydravion when <300 s). Don't refresh on every request: floatplane-downloader 5.19.5 had a bug refreshing before every call and after ~40 refreshes in seconds `auth.floatplane.com` returned **Cloudflare 429 / error 1015 with a 600 s retry** (issue #290). 5. Call `https://www.floatplane.com/api/...` with `Authorization: Bearer ` (floatplaneAPI.ts adds it in a `got` `beforeRequest` hook only for URLs under the base URL; Hydravion/Floaty add it to every API request; Floaty keeps a cookie jar too but relies on Bearer). Measured lifetimes (bmlzootown canary, live 2026-10-11, client `fp-tv-app`, requested `openid offline_access`, granted `openid profile email offline_access`): **access token `expires_in=1800` s (30 min); refresh token `refresh_expires_in=7776000` s (90 days), rotated on every refresh, sliding window** ("Each refresh returned full refresh_expires_in=7776000 from that moment"). Hydravion also handles `refresh_expires_in=0` by assuming a 30-day idle limit. **[V]** DPoP details (FloatNative `DPoPManager.kt`/`.swift`, canary `dpop.mjs`): EC P-256 key stored per device; proof JWT header `{"typ":"dpop+jwt","alg":"ES256","jwk":{kty,crv,x,y}}`, payload `{iat, jti, htm, htu (URL without query), ath = b64url(sha256(access_token)) on resource requests}`; headers `DPoP: ` and `Authorization: DPoP `; honour `DPoP-Nonce` response header and the `use_dpop_nonce` / `invalid_dpop_proof` errors by retrying with the nonce; FloatNative auto-corrects clock skew from the `Date` header on a DPoP 400. **[V]** Website's own auth modes (from `frontend.floatplane.com/user/4.5.25-340-92712da/js/index-DaEmRTqt.js`): enum `cookie | token | oauth-basic | oauth-dpop`; TV config `{baseUrl:"https://auth.floatplane.com", realm:"floatplane", clientId:"fp-tv-app", usesExtendedSecurity:true, linkShortUrl:"floatplane.com/link"}`, `token_endpoint_auth_method:"none"` (public client). **[V]** #### C. `GET /api/v3/user/self` **[V]** ```json {"id":"0123456789abcdef01234567","username":"my_username", "profileImage":{"width":512,"height":512,"path":"https://pbs.floatplane.com/profile_images/default/user12.png","childImages":[{"width":250,"height":250,"path":"…_250x250.png"}]}, "email":"…","displayName":"…","creators":[],"scheduledDeletionDate":null} ``` Used by every client as the "am I logged in" probe (Hydravion uses `/api/v3/user/subscriptions` for that; a 401 → clear tokens). Wasserflug calls `GET /api/v3/status?platform=web&version=…` first (returns `selfUser` or 403). ### 1.3 Subscriptions — `GET /api/v3/user/subscriptions` **[V]** ```json [{"startDate":"2020-09-25T07:35:04.273Z","endDate":"2021-09-25T07:35:04.273Z","paymentID":12345,"interval":"year","paymentCancelled":false, "plan":{"id":"5d48d0306825b5780db93d07","title":"LTT Supporter (1080p)","description":"…","price":"5.00","priceYearly":"50.00","currency":"usd","logo":null,"featured":true,"allowGrandfatheredAccess":false,"discordServers":[],"discordRoles":[]}, "creator":"59f94c0bdd241b70349eb72b"}] ``` `creator` is the creator GUID (24 hex). Duplicates are possible (Wasserflug de-dupes with a Set). Wasserflug shows an error if the list is empty. ### 1.4 Creators **[V]** - `GET /api/v3/creator/info?id=` → `CreatorModelV3` (single creator). - `GET /api/v3/creator/named?creatorURL[0]=linustechtips` → `[CreatorModelV3]` (yt-dlp uses it to resolve `/channel//home/` URLs). - `GET /api/v3/creator/list?search=` → `[CreatorModelV3]` (discovery; FloatNative also calls it with `ids[]=` to batch-fetch creators). - `GET /api/v3/creator/channels/list?ids[0]=` → `[ChannelModel]` (floatplane-downloader uses this per subscription). - v2: `GET /api/v2/creator/info?creatorGUID[0]=…&creatorGUID[1]=…` → `[CreatorModelV2]` (multi-creator in one call; Wasserflug still uses it for livestream refresh). `CreatorModelV3` (trimmed): ```json {"id":"59f94c0bdd241b70349eb72b","owner":{"id":"…","username":"Linus"},"title":"LinusTechTips","urlname":"linustechtips", "description":"…","about":"# We're LinusTechTips\n…","category":{"id":"…","title":"Technology"}, "cover":{"width":1990,"height":519,"path":"https://pbs.floatplane.com/cover_images/…jpeg","childImages":[…]}, "icon":{"width":600,"height":600,"path":"https://pbs.floatplane.com/creator_icons/…jpeg","childImages":[…]}, "liveStream":{"id":"5c13f3c006f1be15e08e05c0","title":"…","description":"

…

","thumbnail":{…},"owner":"…","channel":"","streamPath":"…","offline":{"title":null,"description":null,"thumbnail":null}}, "subscriptionPlans":[…],"discoverable":true,"subscriberCountDisplay":"total","incomeDisplay":false, "defaultChannel":"63fe42c309e691e4e36de93d", "channels":[{"id":"63fe42c309e691e4e36de93d","creator":"59f94c0b…","title":"Linus Tech Tips","urlname":"main","about":"…","order":0,"cover":{…},"card":{…},"icon":{…},"socialLinks":{}}, …], "socialLinks":{},"card":{…}} ``` ### 1.5 Content listing **[V]** `GET /api/v3/content/creator` — params: `id`* (creator GUID), `channel` (channel id), `limit` (clients use 20), `fetchAfter` (integer **offset** = number of posts to skip, "usually a multiple of `limit`"), `search`, `tags[0]=…`, `hasVideo`, `hasAudio`, `hasPicture`, `hasText` (mutually exclusive with the others), `sort=ASC|DESC`, `fromDuration`/`toDuration` (seconds, imply hasVideo), `fromDate`/`toDate` (ISO). Returns `[BlogPostModelV3]`; empty array = end. floatplaneAPI.ts `blogPostsIterable` simply loops `fetchAfter += 20` until an empty page; yt-dlp uses `fetchAfter = page * 20`. `BlogPostModelV3` (trimmed): ```json {"id":"Dw2ms0AgL8","guid":"Dw2ms0AgL8","title":"…","text":"

…

","type":"blogPost", "channel":{…ChannelModel…} /* or just a channel id string in some responses */, "tags":["test"],"attachmentOrder":["TViGzkuIic"], "metadata":{"hasVideo":true,"videoCount":1,"videoDuration":5689,"hasAudio":false,"audioCount":0,"audioDuration":0,"hasPicture":false,"pictureCount":0,"hasGallery":false,"galleryCount":0,"isFeatured":false}, "releaseDate":"2021-10-09T09:29:00.039Z","likes":41,"dislikes":0,"score":41,"comments":28, "creator":{"id":"…","owner":{…},"title":"…","urlname":"…","icon":{…},"liveStream":{…}|null,"defaultChannel":"…","channels":["…"]}, "wasReleasedSilently":false, "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/blogPost_thumbnails//_.jpeg","childImages":[{"width":400,"height":225,"path":"…_400x225.jpeg"},…]}, "isAccessible":true, "videoAttachments":["TViGzkuIic"],"audioAttachments":[],"pictureAttachments":[],"galleryAttachments":[]} ``` Important recent change: in list responses the attachment arrays are now **omitted/empty** — Hydravion's current code comments: "List responses omit attachment ids. Keep them when an older payload still sends them." and "Delivery and playback use the attachment id. The creator list no longer includes it." So always fetch `GET /api/v3/content/post?id=` to get attachment IDs before playback. **[V]** `GET /api/v3/content/creator/list` (multi-creator home feed): `ids[0]=…&ids[1]=…&limit=20` plus pagination objects `fetchAfter[0][creatorId]=…&fetchAfter[0][blogPostId]=…&fetchAfter[0][moreFetchable]=true` (one per creator, taken verbatim from the previous response's `lastElements`). Response `{"blogPosts":[BlogPostModelV3…],"lastElements":[{"creatorId":"…","blogPostId":"…"|null,"moreFetchable":true}]}`. Wasserflug filters out `lastElements` entries with `blogPostId == null` because "Floatplane doesn't like when we send one of these without a blogPostId". **[V]** `GET /api/v3/content/post?id=` → `ContentPostV3Response`: same as above but `channel` is a full `ChannelModel`, `creator` is `CreatorModelV2`, `userInteraction[]`, and the attachments are **full objects**: ```json "videoAttachments":[{"id":"TViGzkuIic","guid":"TViGzkuIic","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…","likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8","thumbnail":{…},"isAccessible":true}], "audioAttachments":[{…,"duration":114,"waveform":{"dataSetLength":…,"highestValue":…,"lowestValue":…,"data":[…]}}] ``` Order attachments by `attachmentOrder`; a post may contain several videos (yt-dlp returns a playlist then). 403 for posts behind a plan you don't have (`isAccessible:false`). `GET /api/v3/content/video?id=` → `ContentVideoV3Response`: ```json {"id":"TViGzkuIic","guid":"…","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…", "likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8", "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/content_thumbnails/TViGzkuIic/…jpeg","childImages":[]}, "isAccessible":true,"blogPosts":["Dw2ms0AgL8"], "timelineSprite":{"width":4960,"height":2610,"path":"https://pbs.floatplane.com/timeline_sprite/…jpeg"}, "progress":123 /* seconds, optional */,"userInteraction":[], "levels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…], "textTracks":[{"id":"…","src":"https://.r2.cloudflarestorage.com/prod-texttrack/text_tracks/…vtt?X-Amz-…&X-Amz-Expires=900…","kind":"caption","language":"en","generated":true,"processing":false}]} ``` Caption URLs are pre-signed (900 s) — fetch promptly. floatplane-downloader skips videos whose `levels` is empty. **[V]** ### 1.6 Delivery **[V]** `GET /api/v3/delivery/info?scenario=onDemand|download|live&entityId=[&outputKind=hls.mpegts|hls.fmp4|dash.mpegts|dash.m4s|flat][&entityKind=video|audio|livestream|auto]` (`entityKind` is in the website's client but not in the community spec). Floaty always sends `outputKind=hls.mpegts` for streaming; Wasserflug sends none (gets HLS TS by default). Response: ```json {"groups":[{"origins":[{"url":"https://cdn-vod-drm2.floatplane.com"}], "variants":[ {"name":"1080-avc1","label":"1080p","url":"/Videos/Lug14XXgLx/1080.mp4/chunk.m3u8?token=","mimeType":"application/x-mpegURL","order":99293376,"hidden":false,"enabled":true, "meta":{"video":{"codec":"avc1.64002a","codecSimple":"avc1","bitrate":{"average":1448573},"width":1920,"height":960,"isHdr":false,"fps":29.97,"mimeType":"video/MP2T"}, "audio":{"codec":"mp4a.40.2","bitrate":{"average":93340,"maximum":93340},"channelCount":2,"samplerate":48000,"mimeType":"video/MP2T"}}}, {"name":"2160-avc1","label":"4K","url":"/Videos/Lug14XXgLx/2160.mp4/chunk.m3u8?token=", …}]}]} ``` - `download` scenario: origins are edge servers (`https://edge01-na.floatplane.com`, `queryUrl`, `datacenter{latitude,longitude,countryCode,regionCode}`) and variant URLs are plain MP4s `/Videos//1080.mp4?token=` with `mimeType: video/mp4`. - `live` scenario: `{"origins":[{"url":"https://.us-east-1.playback.live-video.net"}],"variants":[{"name":"live-abr","label":"Auto","url":"/api/video/v1/us-east-1..channel..m3u8?allow_source=false&token=","mimeType":"application/x-mpegURL","meta":{"live":{"lowLatencyExtension":"ivshls"}}}]}` (Amazon IVS). - `meta.common.access.deniedReason` ∈ `isMissingPermission | isProcessing | isBroken` with `deniedMessage` when a variant is not available to you. - **Building the final URL**: if `variant.url` is absolute use it; else `origin.url` (trailing slash trimmed) + `variant.url`; prefer `variant.origins` over `group.origins` (Wasserflug `DeliveryHelper.getBestUrl`, random origin); floatplane-downloader round-robins origins for downloads; Hydravion/yt-dlp just take `groups[0].origins[0].url + variant.url`. The spec says v3 URLs contain **no template placeholders** (that was v2). - Quality selection in practice: filter `enabled && !hidden`; sort by `order` (bigger = better) or `meta.video.height`; match the user's preference on `label` ("1080p", "4K") or `name` ("1080-avc1"); Wasserflug drops variants taller than the screen (×1.15 tolerance; note LTT 2:1 videos are 2160×1080 and 4320×2160). - Token: the `?token=` is a JWT. The spec's v2 example decodes to `{"ressourcePath":"/Videos/TViGzkuIic/360.mp4/chunk.m3u8","userId":"…","iat":1633797311,"exp":1633818911}` → **6-hour lifetime** in that example (older format); Floaty checks an `expires` epoch query param on download URLs and re-requests delivery info one minute before expiry. Treat tokens as short-lived (hours) and per-user/per-resource. **[V]** for the example, **[I]** for current lifetime. - **Does the CDN need the cookie/bearer or just the signed URL?** Verified behaviour of clients: all current clients send `Authorization` (Bearer/DPoP) to the CDN as well; FloatNative documents that the **master playlist and the AES-128 key request require session-bound auth, media segments do not**; yt-dlp pulls the key URL from `#EXT-X-KEY:METHOD=AES-128,URI="https://…"` and fetches it with the same cookies/headers. Whether a `download`-scenario MP4 URL plays with the token alone is **not verified** (Floaty's downloader also goes through its Bearer-adding HTTP client). **[V]/[I]** as marked. Test both. - Older endpoint `GET /api/v2/cdn/delivery?type=vod|aod|live|download&guid=` (or `&creator=` for live): `{"cdn":"https://cdn-vod-drm2.floatplane.com","strategy":"cdn","resource":{"uri":"/Videos/TViGzkuIic/{qualityLevels}.mp4/chunk.m3u8?token={qualityLevelParams.token}","data":{"qualityLevels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…],"qualityLevelParams":{"360":{"token":""},…}}}}` — substitute `{qualityLevels}` with `qualityLevels[].name` and `{qualityLevelParams.X}` with `qualityLevelParams[name][X]`; later revisions used numeric keys (`{qualityLevelParams.1}` file, `{qualityLevelParams.2}`/`.4` token — Floaty and old yt-dlp). yt-dlp's old code: `path.replace(f'{{qualityLevelParams.{i}}}', val)` for each param then `urljoin(stream['cdn'], path)`. yt-dlp switched to v3 on 2025-11-10 (PR #14984) because the v2 shape broke. Treat v2 as deprecated. **[V]** - Floaty's download trick: take the HLS variant URL, strip `/chunk.m3u8`, replace `/Videos/` with `/`, append `&attachment=true&filename=` to get a browser-style download. **[V]** (behaviour), **[I]** (stability). - Rate limit evidence: spec test `test_Flow.py` sleeps 35 s between delivery calls ("Current rate limit is 2 req per min"); floatplane-downloader uses a semaphore of 2 with a 65 s hold ("only call the delivery endpoint twice a minute at most"); Floaty budgets 2 per 5 minutes and parses `Retry-After` (default 300 s) and body text `error code: 1015`. **[V]** ### 1.7 Live streams **[V]** - Find the stream via `creator.liveStream` (`CreatorModelV2/V3`, also on posts' `creator`). `liveStream.id` → `GET /api/v3/delivery/info?scenario=live&entityId=` → IVS HLS URL with `token` (see above). Old way: `/api/v2/cdn/delivery?type=live&creator=`. - Live/offline detection: Hydravion (`streamCheckTask.brs`) and Wasserflug (`LivestreamViewModel`) simply `GET` the m3u8 URL every ~3–5 s; HTTP 200 = live, anything else = offline. `liveStream.offline{title,description,thumbnail}` holds the offline card. Channels (sub-channels) don't have their own livestreams ("Individual channels do not currently support livestreams" — Hydravion). FloatNative plays live URLs natively without DPoP/headers (token in URL). The AsyncAPI spec covers chat on `wss://chat.floatplane.com/sails.io/` (needs the `sails.sid` cookie — the one remaining use of the cookie per bmlzootown). ### 1.8 Watch progress **[V]** - Batch read: `POST /api/v3/content/get/progress` body `{"ids":["",…],"contentType":"blogPost"}` → `[{"id":"3qZrz5ugsa","progress":0},{"id":"mhhrOfndeF","progress":100}]` — **percentage 0–100** of the first video/audio attachment; don't use it to seek. - Write: `POST /api/v3/content/progress` body `{"id":"