# Exchange an approved device code Source: https://docs.punchplay.tv/api-reference/authentication/exchange-an-approved-device-code /openapi.yaml post /api/platform/v1/auth/device/token # Exchange an authorization code or refresh token Source: https://docs.punchplay.tv/api-reference/authentication/exchange-an-authorization-code-or-refresh-token /openapi.yaml post /api/platform/v1/oauth/token # Refresh a device-flow token Source: https://docs.punchplay.tv/api-reference/authentication/refresh-a-device-flow-token /openapi.yaml post /api/platform/v1/auth/refresh # Revoke an access or refresh token Source: https://docs.punchplay.tv/api-reference/authentication/revoke-an-access-or-refresh-token /openapi.yaml post /api/platform/v1/oauth/revoke # Show the OAuth consent screen Source: https://docs.punchplay.tv/api-reference/authentication/show-the-oauth-consent-screen /openapi.yaml get /api/platform/v1/oauth/authorize # Start device authorization Source: https://docs.punchplay.tv/api-reference/authentication/start-device-authorization /openapi.yaml post /api/platform/v1/auth/device/code # Get upcoming releases for the user's saved titles Source: https://docs.punchplay.tv/api-reference/calendar/get-upcoming-releases-for-the-users-saved-titles /openapi.yaml get /api/platform/v1/calendar # Add a title to the collection Source: https://docs.punchplay.tv/api-reference/collection/add-a-title-to-the-collection /openapi.yaml post /api/platform/v1/collection # Get the user's cursor-paginated collection Source: https://docs.punchplay.tv/api-reference/collection/get-the-users-cursor-paginated-collection /openapi.yaml get /api/platform/v1/me/collection # Remove a collection item Source: https://docs.punchplay.tv/api-reference/collection/remove-a-collection-item /openapi.yaml delete /api/platform/v1/collection/{id} # Claim completed weekly challenge rewards idempotently Source: https://docs.punchplay.tv/api-reference/community/claim-completed-weekly-challenge-rewards-idempotently /openapi.yaml post /api/platform/v1/me/community-stats # Get the user's community statistics and weekly challenges Source: https://docs.punchplay.tv/api-reference/community/get-the-users-community-statistics-and-weekly-challenges /openapi.yaml get /api/platform/v1/me/community-stats # Change a history entry's watched time Source: https://docs.punchplay.tv/api-reference/history/change-a-history-entrys-watched-time /openapi.yaml patch /api/platform/v1/watch-history/{id} # Delete a history entry Source: https://docs.punchplay.tv/api-reference/history/delete-a-history-entry /openapi.yaml delete /api/platform/v1/watch-history/{id} # Delete matching episode history for a season Source: https://docs.punchplay.tv/api-reference/history/delete-matching-episode-history-for-a-season /openapi.yaml delete /api/platform/v1/title/{type}/{id}/season/{season}/watch # Delete matching history for a title Source: https://docs.punchplay.tv/api-reference/history/delete-matching-history-for-a-title /openapi.yaml delete /api/platform/v1/title/{type}/{id}/history # Get cursor-paginated watch history Source: https://docs.punchplay.tv/api-reference/history/get-cursor-paginated-watch-history /openapi.yaml get /api/platform/v1/me/history # Log a movie watch Source: https://docs.punchplay.tv/api-reference/history/log-a-movie-watch /openapi.yaml post /api/platform/v1/title/{type}/{id}/history # Log multiple episode watches for a season Source: https://docs.punchplay.tv/api-reference/history/log-multiple-episode-watches-for-a-season /openapi.yaml post /api/platform/v1/title/{type}/{id}/season/{season}/watch # Add an item to a list Source: https://docs.punchplay.tv/api-reference/lists/add-an-item-to-a-list /openapi.yaml post /api/platform/v1/lists/{id}/items Owners and collaborators can add items to ordinary lists and watchlists. Externally managed and dynamic lists reject manual item changes. # Create a list Source: https://docs.punchplay.tv/api-reference/lists/create-a-list /openapi.yaml post /api/platform/v1/lists # Delete a list Source: https://docs.punchplay.tv/api-reference/lists/delete-a-list /openapi.yaml delete /api/platform/v1/lists/{id} Only the owner can delete a list. Watchlists cannot be deleted. # Get a dynamic list's paginated items Source: https://docs.punchplay.tv/api-reference/lists/get-a-dynamic-lists-paginated-items /openapi.yaml get /api/platform/v1/lists/{id}/items # Get list details Source: https://docs.punchplay.tv/api-reference/lists/get-list-details /openapi.yaml get /api/platform/v1/lists/{id} # Get the user's cursor-paginated list summaries Source: https://docs.punchplay.tv/api-reference/lists/get-the-users-cursor-paginated-list-summaries /openapi.yaml get /api/platform/v1/me/lists externalSource is non-null for externally managed lists. Those lists, and dynamic-list items, are read-only through write operations. # Remove an item from a list Source: https://docs.punchplay.tv/api-reference/lists/remove-an-item-from-a-list /openapi.yaml delete /api/platform/v1/lists/{id}/items/{itemId} Owners and collaborators can remove items from ordinary lists and watchlists. Externally managed and dynamic lists reject manual item changes. # Update a list Source: https://docs.punchplay.tv/api-reference/lists/update-a-list /openapi.yaml patch /api/platform/v1/lists/{id} Only the owner can update an ordinary or dynamic list. Watchlists and externally managed lists cannot be updated. # Get cursor-paginated notifications Source: https://docs.punchplay.tv/api-reference/notifications/get-cursor-paginated-notifications /openapi.yaml get /api/platform/v1/me/notifications # Get push notification preferences Source: https://docs.punchplay.tv/api-reference/notifications/get-push-notification-preferences /openapi.yaml get /api/platform/v1/me/notification-preferences # Get the unread notification count Source: https://docs.punchplay.tv/api-reference/notifications/get-the-unread-notification-count /openapi.yaml get /api/platform/v1/me/notifications/unread-count # Mark one notification, or all notifications, as read Source: https://docs.punchplay.tv/api-reference/notifications/mark-one-notification-or-all-notifications-as-read /openapi.yaml post /api/platform/v1/me/notifications/read # Update push notification preferences Source: https://docs.punchplay.tv/api-reference/notifications/update-push-notification-preferences /openapi.yaml patch /api/platform/v1/me/notification-preferences # Dismiss an in-progress item Source: https://docs.punchplay.tv/api-reference/playback/dismiss-an-in-progress-item /openapi.yaml delete /api/platform/v1/playback/in-progress/{id} # Get continue-watching details for a show Source: https://docs.punchplay.tv/api-reference/playback/get-continue-watching-details-for-a-show /openapi.yaml get /api/platform/v1/me/continue-watching/{showId} # Get continue-watching shows Source: https://docs.punchplay.tv/api-reference/playback/get-continue-watching-shows /openapi.yaml get /api/platform/v1/me/continue-watching # Get resumable playback items Source: https://docs.punchplay.tv/api-reference/playback/get-resumable-playback-items /openapi.yaml get /api/platform/v1/playback/in-progress # Get the most recent active playback item Source: https://docs.punchplay.tv/api-reference/playback/get-the-most-recent-active-playback-item /openapi.yaml get /api/platform/v1/playback/now-playing # Send a playback lifecycle or progress update Source: https://docs.punchplay.tv/api-reference/playback/send-a-playback-lifecycle-or-progress-update /openapi.yaml post /api/platform/v1/playback/{action} An incomplete stop creates inactive resumable progress even when no preceding live session exists. Newer snapshots may reuse the same playback_session_id but must use a unique event_id and a later event_created_at. A standalone pause without an active session returns 409. A watched stop additionally requires history:write because it creates watch history. # Stream playback changes with server-sent events Source: https://docs.punchplay.tv/api-reference/playback/stream-playback-changes-with-server-sent-events /openapi.yaml get /api/platform/v1/playback/events # Get display and regional preferences Source: https://docs.punchplay.tv/api-reference/profile/get-display-and-regional-preferences /openapi.yaml get /api/platform/v1/me/preferences # Get editable profile details Source: https://docs.punchplay.tv/api-reference/profile/get-editable-profile-details /openapi.yaml get /api/platform/v1/me/profile # Get the authenticated user and granted scopes Source: https://docs.punchplay.tv/api-reference/profile/get-the-authenticated-user-and-granted-scopes /openapi.yaml get /api/platform/v1/me # Update display and regional preferences Source: https://docs.punchplay.tv/api-reference/profile/update-display-and-regional-preferences /openapi.yaml patch /api/platform/v1/me/preferences # Update profile details Source: https://docs.punchplay.tv/api-reference/profile/update-profile-details /openapi.yaml patch /api/platform/v1/me/profile # Get public API health Source: https://docs.punchplay.tv/api-reference/public-api/get-public-api-health /openapi.yaml get /api/public/v1/status # Get the public community overview Source: https://docs.punchplay.tv/api-reference/public-api/get-the-public-community-overview /openapi.yaml get /api/public/v1/community/overview # Discover movies, shows, or anime Source: https://docs.punchplay.tv/api-reference/public-catalog/discover-movies-shows-or-anime /openapi.yaml get /api/public/v1/catalog/discover # Get trending movies, shows, or anime Source: https://docs.punchplay.tv/api-reference/public-catalog/get-trending-movies-shows-or-anime /openapi.yaml get /api/public/v1/catalog/trending # Search movies, shows, or anime Source: https://docs.punchplay.tv/api-reference/public-catalog/search-movies-shows-or-anime /openapi.yaml get /api/public/v1/catalog/search # Get a public user profile payload Source: https://docs.punchplay.tv/api-reference/public-users/get-a-public-user-profile-payload /openapi.yaml get /api/public/v1/users/{username} # Get the user's favourites Source: https://docs.punchplay.tv/api-reference/ratings/get-the-users-favourites /openapi.yaml get /api/platform/v1/me/favourites # Get the user's ratings Source: https://docs.punchplay.tv/api-reference/ratings/get-the-users-ratings /openapi.yaml get /api/platform/v1/me/ratings # Get the user's title watch statuses Source: https://docs.punchplay.tv/api-reference/ratings/get-the-users-title-watch-statuses /openapi.yaml get /api/platform/v1/me/watch-status Returns all title-level watch statuses by default. Filter with status, including DROPPED. Setting a show's status to DROPPED removes it from Continue Watching, Up Next, and upcoming/calendar recommendations while preserving watch history. # Rate, favourite, plan, or set watch status Source: https://docs.punchplay.tv/api-reference/ratings/rate-favourite-plan-or-set-watch-status /openapi.yaml patch /api/platform/v1/title/{type}/{id}/interact # Get ordered account changes and deletion tombstones Source: https://docs.punchplay.tv/api-reference/sync/get-ordered-account-changes-and-deletion-tombstones /openapi.yaml get /api/platform/v1/me/sync/changes Omit cursor to begin a new sync. A resetRequired response supplies the high-water cursor to retain while the client downloads resource snapshots. Cursors expire after 90 days. # Idempotently add or remove up to 100 watchlist items Source: https://docs.punchplay.tv/api-reference/sync/idempotently-add-or-remove-up-to-100-watchlist-items /openapi.yaml post /api/platform/v1/sync/watchlist # Idempotently insert up to 100 history items Source: https://docs.punchplay.tv/api-reference/sync/idempotently-insert-up-to-100-history-items /openapi.yaml post /api/platform/v1/sync/history # Idempotently update up to 100 ratings or title interactions Source: https://docs.punchplay.tv/api-reference/sync/idempotently-update-up-to-100-ratings-or-title-interactions /openapi.yaml post /api/platform/v1/sync/ratings # Page through the current state of one sync resource Source: https://docs.punchplay.tv/api-reference/sync/page-through-the-current-state-of-one-sync-resource /openapi.yaml get /api/platform/v1/me/sync/snapshot # Get a show's season and episodes Source: https://docs.punchplay.tv/api-reference/titles/get-a-shows-season-and-episodes /openapi.yaml get /api/platform/v1/title/show/{id}/season/{season} # Get episode metadata Source: https://docs.punchplay.tv/api-reference/titles/get-episode-metadata /openapi.yaml get /api/platform/v1/title/show/{id}/season/{season}/episode/{episode} # Get title metadata and scope-dependent user state Source: https://docs.punchplay.tv/api-reference/titles/get-title-metadata-and-scope-dependent-user-state /openapi.yaml get /api/platform/v1/title/{type}/{id} Private fields are returned only when the token also has ratings:read, history:read, lists:read, or collection:read. # Get trophy progress and earned totals Source: https://docs.punchplay.tv/api-reference/trophies/get-trophy-progress-and-earned-totals /openapi.yaml get /api/platform/v1/me/trophies # Authentication Source: https://docs.punchplay.tv/authentication Implement PunchPlay auth for browser, native, desktop, and TV apps. The PunchPlay Platform API supports two app-bound auth patterns: * Authorization code + PKCE for browser, website, and native apps that can receive a callback * Device code for TV, desktop, CLI, and clients without a convenient browser callback Both flows issue scoped bearer tokens for `/api/platform/v1`. ## Register your app first Create a developer app at [Get API key](https://punchplay.tv/developers). Each app receives a stable `client_id`. * Confidential clients also receive a `client_secret` * Public clients use `client_id` only * Every app defines its maximum `allowed_scopes` * The approval screen shows the app name and requested scopes before the user approves access ## Client types | Client type | Best for | Credentials | Guidance | | - | - | - | - | | Public | Browser apps, native apps, desktop apps, TV apps | `client_id` | Do not embed a secret you cannot protect | | Confidential | Server-backed apps and websites | `client_id` + `client_secret` | Keep the secret server-side only | ## Current scopes Apps can currently request these scopes: | Scope | Allows | | - | - | | `profile:read` | Basic profile, title metadata, seasons, episodes, and calendar | | `history:read` | Watch-history reads | | `history:write` | Log, edit, and remove watches | | `lists:read` | Read the user's lists and list details | | `lists:write` | Create, edit, delete, and populate lists | | `ratings:read` | Ratings, favourites, watch statuses, and title interaction state | | `ratings:write` | Ratings, favourites, planning, and show-status changes | | `collection:read` | Read the physical and digital collection | | `collection:write` | Add and remove collection items | | `notifications:read` | Read notifications, unread counts, and notification preferences | | `notifications:write` | Mark notifications read and update notification preferences | | `trophies:read` | Read trophy progress and earned totals | | `profile:write` | Update profile details and app preferences | | `playback:read` | In-progress and Continue Watching state | | `playback:write` | Playback updates and dismissing progress | | `events:read` | Playback server-sent events | If a request omits `scope`, PunchPlay grants the app's configured `allowed_scopes`. If a request asks for a scope outside the app's `allowed_scopes`, the request fails with `invalid_scope`. `email:read` is reserved and is not available to developer apps. Use the stable user ID returned by `GET /api/platform/v1/me` when linking accounts. PunchPlay's `/me/push-devices` routes are also reserved for developer apps explicitly marked as first-party. The notification scopes remain available to third-party apps for inbox, unread-state, and preference features. Third-party apps should only redirect users to the `GET /api/platform/v1/oauth/authorize` endpoint. The consent screen's POST decision handler is used by PunchPlay's own signed-in, same-origin UI; it is not an app approval API, even though it appears in the generated reference with `security: []`. ## Flow selection Use this rule: * Use authorization code + PKCE for browser, website, and native apps that can redirect a user and receive a callback * Use device code for TV, desktop, CLI, and clients that cannot handle a normal browser callback cleanly ## Authorization code + PKCE ### 1. Redirect the user to PunchPlay ```text theme={null} GET https://punchplay.tv/api/platform/v1/oauth/authorize ``` Required query parameters: * `response_type=code` * `client_id=YOUR_CLIENT_ID` * `redirect_uri=YOUR_REGISTERED_REDIRECT_URI` * `code_challenge=BASE64URL_SHA256_OF_CODE_VERIFIER` * `code_challenge_method=S256` Optional: * `scope=profile:read playback:read playback:write events:read` * `state=YOUR_OPAQUE_STATE` Example: ```text theme={null} https://punchplay.tv/api/platform/v1/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fpunchplay%2Fcallback&code_challenge=YOUR_PKCE_CHALLENGE&code_challenge_method=S256&scope=profile%3Aread%20playback%3Aread%20playback%3Awrite&state=YOUR_STATE ``` PunchPlay authenticates the user if needed, shows the consent screen, then redirects with HTTP `302` back to your `redirect_uri` with: * `code` * `state` if you sent one * `scope` If the request cannot be completed, PunchPlay redirects back with an `error`. Redirect-based failures may also include `request_id` so the developer has something concrete to report. Users can review and revoke approved apps later from `https://punchplay.tv/settings/connected-apps`. Store the `state` value with the pending authorization transaction and compare it exactly on callback. Keep the original PKCE verifier private until the code exchange completes; a verifier is not a substitute for state validation. ### 2. Exchange the code for tokens ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "code=AUTHORIZATION_CODE" \ -d "redirect_uri=https://example.com/auth/punchplay/callback" \ -d "code_verifier=YOUR_ORIGINAL_PKCE_VERIFIER" ``` Public apps send `client_id` only. Confidential apps send both `client_id` and `client_secret`. Successful response: ```json theme={null} { "access_token": "access_token", "refresh_token": "refresh_token", "token_type": "bearer", "expires_in": 3600, "refresh_expires_in": 31536000, "scope": "profile:read playback:read playback:write" } ``` ### 3. Refresh the access token ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "refresh_token=REFRESH_TOKEN" ``` Successful refresh returns a rotated refresh token. Replace the old one immediately. ```json theme={null} { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "bearer", "expires_in": 3600, "refresh_expires_in": 31415926, "scope": "profile:read playback:read playback:write" } ``` ## Device code ### 1. Request a device code ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/auth/device/code \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "scope": "profile:read playback:read playback:write events:read" }' ``` Public clients omit `client_secret`. Response fields: | Field | Meaning | | - | - | | `user_code` | Short code shown to the user | | `device_code` | Opaque code your app uses to poll | | `verification_uri` | Approval page | | `verification_uri_complete` | Approval page with code prefilled | | `verification_uri_qr` | QR code image data | | `expires_in` | Device-code lifetime in seconds | | `scope` | The scope set that will be attached to issued tokens | ### 2. Poll the token endpoint ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/auth/device/token \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "device_code": "raw_device_code", "device_name": "PunchPlay Companion", "device_id": "living-room-mac" }' ``` Pending approval returns HTTP `400` with the standard OAuth device-flow error: ```json theme={null} { "error": "authorization_pending", "message": "Device code has not been approved yet.", "request_id": "ppreq_..." } ``` Success returns: ```json theme={null} { "access_token": "access_token", "refresh_token": "refresh_token", "token_type": "bearer", "expires_in": 3600, "refresh_expires_in": 31536000, "username": "scott", "scope": "profile:read playback:read playback:write events:read" } ``` ### 3. Refresh the access token ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{ "client_id":"YOUR_CLIENT_ID", "client_secret":"YOUR_CLIENT_SECRET", "refresh_token":"REFRESH_TOKEN" }' ``` Successful refresh returns a rotated refresh token. Replace the old one immediately. ```json theme={null} { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "bearer", "expires_in": 3600, "refresh_expires_in": 31415926, "scope": "profile:read playback:read playback:write events:read" } ``` ## Authenticated requests Use the access token in the `Authorization` header: ```http theme={null} Authorization: Bearer ACCESS_TOKEN ``` Platform tokens are scoped to the platform namespace and intended for endpoints under `/api/platform/v1`. ## Token lifetimes Access tokens live for **one hour** (`expires_in: 3600`). Refresh tokens live for a year, and rotate on every use — each refresh returns a new one, and only the newest pair is valid. If a refresh response is lost (a timeout, a dropped connection, a crash before you saved the result), retry with the same refresh token within 60 seconds of the original request. You get a new pair; the pair from the lost response is no longer valid. Retries still count against the refresh rate limits, so honour `Retry-After` on a `429`. Presenting any older refresh token fails with `invalid_grant`. If it looks copied — the previous token more than 60 seconds after it was exchanged, or a pair that a retry has since replaced — the whole connection is revoked and the user has to reconnect. Your client must refresh. Either refresh shortly before `expires_in` elapses, or retry once on a `401` after refreshing. Both work; do not do neither. Serialise your refreshes. Because refresh tokens rotate, two refreshes running in parallel each return a different pair and only one stays valid; keep the wrong one and the user is signed out. If several requests can hit `401` at once, funnel them through a single refresh, and persist the new pair before using it. The in-repository `@punchplay/api-client` handles this for you — pass `onUnauthorized` and it refreshes, retries once, and collapses concurrent failures into one refresh. The package is not currently published to the public npm registry; use the local or tarball instructions in its README. Access tokens are deliberately short lived because they are bearer credentials: anyone holding one is the user until it expires. Revocation covers leaks you notice; a short lifetime covers the ones you do not. ## Debugging auth failures Platform auth errors include: * `request_id` in the JSON body * `X-PunchPlay-Request-Id` in the response headers PunchPlay also persists platform request logs keyed by `request_id`. When reporting a failure, capture: * `request_id` * UTC timestamp * endpoint path * `client_id` * status code * raw error body For developer questions, use the [PunchPlay Discord](https://discord.gg/pPwct597Ut) and include the request ID. Never post access tokens, refresh tokens, or client secrets. ## Security guidance * Treat `client_secret`, refresh tokens, and access tokens as sensitive credentials. * Do not embed `client_secret` in native apps, browser apps, or sample code intended for public distribution. * Use public clients when your app cannot safely hold a secret. * Keep `allowed_scopes` as narrow as possible for each app. * Rotate secrets immediately if you suspect they were exposed. * Deactivate an app if you need to revoke its issued tokens quickly. * If your app runs entirely in the browser, keep in mind that standard `EventSource` clients cannot attach custom authorization headers. ## Recommended polling behavior Poll no faster than every five seconds until the user approves the request or the device code expires. Device authorization limits are counted per end-user IP and per device code rather than across your whole app, so one user's retries do not lock out your other users. If you get a `429`, wait for the `Retry-After` seconds before polling again. Authentication JSON and form bodies are limited to 64 KiB and must arrive within 10 seconds. Oversized bodies return `413`; timed-out bodies return `408`. ## Check the authenticated identity After obtaining a token, verify who you are acting as: ```bash theme={null} curl https://punchplay.tv/api/platform/v1/me \ -H "Authorization: Bearer ACCESS_TOKEN" ``` # Changelog Source: https://docs.punchplay.tv/changelog Track externally visible changes to PunchPlay's developer platform. ## 2026-10-05 ### Changed * Refreshing with the previous refresh token within 60 seconds of exchanging it now returns a new token pair instead of `invalid_grant`, so a lost refresh response no longer forces the user to reconnect. The pair from the lost response is no longer valid; only the newest pair is * Presenting a refresh token pair that such a retry replaced now revokes the connection, as presenting the previous token after the 60 seconds already did * Two concurrent refreshes with the same refresh token now both succeed instead of one failing with `invalid_grant`; only the later pair stays valid ## 2026-09-29 ### Added * Continue Watching summaries now document fields they already returned: `currentSeasonNumber`, `currentSeasonName`, `currentSeasonEpisodesWatched`, `currentSeasonEpisodesTotal`, `currentSeasonPercent`, `currentSeasonMinutesRemaining`, `episodeRuntimeSource`, and `episodeBackdropUrl` * Continue Watching summaries now include `earlierUnwatchedSourceSeason`, `earlierUnwatchedSourceEpisode`, and `earlierUnwatchedEpisodeTitle` for linking to the earlier gap * Continue Watching summaries now include `currentSeasonEpisodesUnaired`, the announced episodes of the current season that have not aired yet ### Changed * `earlierUnwatchedSeason` and `earlierUnwatchedEpisode` now point at the earliest gap in a season the user has started before falling back to seasons they never started. `earlierUnwatchedCount` still counts every aired unwatched episode before the last-watched one * `currentSeasonMinutesRemaining` now sums each remaining episode's listed runtime instead of multiplying by an average ### Fixed * `GET /api/platform/v1/title/{type}/{id}` no longer includes the user's private per-title note in `interaction`. The field was never documented; `interaction` now contains exactly `watchedAt`, `rating`, `isFavourite`, and `showStatus` ## 2026-09-22 ### Added * Continue Watching summaries now include `earlierUnwatchedSeason`, `earlierUnwatchedEpisode`, and `earlierUnwatchedCount`, allowing clients to surface aired gaps separately from the forward “next episode” action * Continue Watching summaries now include `releasePhase`, a schedule-based display state separate from the existing provider `status` ### Fixed * `nextSeason` and `nextEpisode` now advance from the last-watched episode instead of rewinding to an earlier unwatched gap, and continue to exclude unaired episodes * Fully caught-up shows remain in Continue Watching when their next episode is announced, even when it does not yet have an air date * Continue Watching and recent-history surfaces replace identifier-shaped stored titles such as `tmdb:124101` with canonical catalog names when available; show years use the canonical premiere year ### Updated * `nextEpisodeToAir.airDate` is explicitly optional so clients can represent announced but unscheduled episodes ## 2026-09-19 ### Fixed * Setting a show's `showStatus` to `DROPPED` now removes it from Continue Watching, Up Next, and upcoming/calendar recommendations without deleting watch history * Site and Platform API drop/undrop actions now update the same state, including canonical TMDB/TVDB aliases; setting another status or `null` restores the show ### Updated * Ratings, favourites, and watch-status library endpoints now honor `pageSize` (capped at 100) * Watch-status reads now return all title-level statuses by default and can be filtered explicitly with `status=DROPPED` ## 2026-09-05 ### Documentation updates * Corrected quickstart scopes and playback examples. * Fixed Kotlin authentication and sync examples. * Clarified pagination, rate limits, live events, and playback progress. * Updated the OpenAPI reference and SDK installation guidance. ## 2026-08-09 ### Fixed * An incomplete `POST /api/platform/v1/playback/stop` now creates inactive Continue Watching progress without requiring a preceding live playback event * A later passive resume snapshot can update the same stable `playback_session_id` instead of being silently deduped after the first stop * Resume snapshots are ordered by provider `event_created_at` rather than database write time, preventing delayed older snapshots from replacing newer progress * Unexpected playback processing failures now return the standard error envelope and request ID and are retained in platform request logs * Collection creation no longer returns a successful response with a null resource ID after a uniqueness race ### Updated * Playback payloads now reject unknown fields and invalid ranges. `progress` is a ratio from `0` to `1` and can derive `position_seconds` when `duration_seconds` is supplied * Playback failures now distinguish `400 invalid_request`, `409 playback_state_conflict`, `422 media_not_found`, and `500 server_error` * The Platform API contract is now `1.0.0-beta.4` ### Breaking * Playback writes now require `event_id`, `event_created_at`, `media_type`, `title`, and `playback_session_id`. Episode writes also require `season` and `episode` * A standalone `pause` now returns `409 playback_state_conflict` instead of reporting success without creating visible progress ### Clarified * Passive resume imports should send one incomplete `stop`; `progress` followed by `pause` is reserved for live playback state ## 2026-07-29 ### Security * Access tokens now expire after **one hour** instead of 30 days, for both the OAuth and device-code flows. They're bearer credentials with no proof of possession and travel widely — addon config files, mobile storage, proxy logs, crash reports — so a short lifetime bounds the exposure window from leaks that go unnoticed. Refresh promptly on `401`, or shortly before `expires_in` elapses * Refresh tokens already rotated on use; a consumed one is now recognised as **replayed** rather than merely invalid. Replaying a refresh token revokes its entire chain, since a legitimate client already exchanged it ### Fixed * The change feed could permanently skip a change. Ids are assigned when a change is written but only become visible when its transaction commits, so two changes committing out of order could leave one behind an advanced cursor, silently desynchronising a mirror. Cursors now also record which transactions were open when issued, and re-check them on the next read ### Updated * Change-feed delivery is now explicitly **at-least-once**. Apply changes idempotently, keyed on `resource` and `resourceId` — receiving one twice is normal. Existing cursors keep working and upgrade automatically on their next read ## 2026-07-28 ### Added * Bulk writes at `POST /api/platform/v1/sync/history`, `/sync/ratings`, and `/sync/watchlist`, accepting up to 100 items and 512 KiB per request * A durable change feed at `GET /api/platform/v1/me/sync/changes` and per-resource backfill at `GET /api/platform/v1/me/sync/snapshot`, so large clients mirror an account instead of re-polling library endpoints. Cursors stay valid for 90 days * Required `Idempotency-Key` on every bulk write, with results replayed for 24 hours * Token revocation at `POST /api/platform/v1/oauth/revoke` (RFC 7009) * OAuth server metadata at `/.well-known/oauth-authorization-server` (RFC 8414), so standard OAuth libraries can discover the endpoints * `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` on every platform response, exposed via CORS for browser clients * Cursor pagination on `GET /api/platform/v1/me/collection` and `GET /api/platform/v1/me/lists` via `limit` and `cursor`, returning `nextCursor` ### Updated * Every authenticated endpoint now enforces a per-token bucket (300 reads or 120 writes per minute) and a per-app bucket (6,000 per minute across all its users). Endpoint-specific limits apply on top of these * Bulk sync accepts 512 KiB bodies rather than the general 64 KiB ceiling, so a full 100-item batch of long-titled entries is no longer rejected * Every `429` now carries `Retry-After` ### Fixed * `/.well-known/oauth-authorization-server` returned an `issuer` with a trailing slash. RFC 8414 requires it to match the string the well-known URI was built from, and strict OAuth clients abandoned discovery on the mismatch ### Breaking * `GET /api/platform/v1/me/collection` and `GET /api/platform/v1/me/lists` now return a bounded page (100 and 50 by default) plus `nextCursor`, where they previously returned the entire set. Follow `nextCursor` to retrieve everything * A stale `Idempotency-Key` whose original request died before recording its result now returns `409 idempotency_indeterminate` instead of silently re-running the write. That mutation may already have applied: reconcile through `/me/sync/changes` and retry with a **new** key ## 2026-07-27 ### Updated * Platform errors now consistently return `error`, `message`, and `request_id`, including delegated list, history, rating, collection, and profile failures * Platform JSON and form bodies are capped at 64 KiB with a 10-second read timeout; the OpenAPI contract now documents `408` and `413` responses * OAuth and device-flow status contracts now match production, including HTTP `400` for `authorization_pending`, `401` for invalid clients, and explicit `302` authorization redirects * List and collection writes now derive anime classification from canonical TVDB identity when clients omit the optional compatibility hint ## 2026-07-26 ### Added * Stable anime search, trending, and discovery through the public catalog API * Explicit anime classification on collection items while preserving their routable movie/show kind ### Updated * Anime titles use TMDB base identities with TVDB enrichment and official episode ordering for mapped series * Playback, list, collection, SDK, and OpenAPI contracts now distinguish anime category from movie/show kind * Legacy provider-ID translation remains an internal import and redirect compatibility concern ## 2026-07-23 ### Updated * All 16 developer-app scopes are now listed consistently across the Authentication guide, native-app guide, and OpenAPI reference * PunchPlay's APNs device-registration routes are enforced as first-party-only; third-party apps retain notification inbox, unread-state, and preference access * Authenticated Platform API operations now publish concrete request and success-response schemas instead of generic JSON placeholders * Public catalog and community rate limits are documented alongside the existing public-profile limit ## 2026-07-19 ### Added * Stable public catalog search, trending, and discovery endpoints * Stable public community overview and authenticated community-stat endpoints * Native notification inbox, unread count, mark-read, and notification-preference endpoints * First-party-only APNs push-device registration and removal endpoints * Native trophy, profile, and user-preference endpoints * Four least-privilege scopes: `notifications:read`, `notifications:write`, `trophies:read`, and `profile:write` ### Updated * Native clients can keep discovery, community, notifications, trophies, profile editing, and preferences inside the app * Sensitive account operations such as password, email, imports, connected services, and account deletion remain web-only * The recommended first-party mobile authorization set now contains 16 scopes ### Notes * Anime discovery was not yet included in that mobile-client release * Third-party apps can use the notification inbox and preference APIs, but PunchPlay's APNs device-registration routes are reserved for first-party clients ## 2026-07-18 ### Added * Platform title, season, and episode detail endpoints for native clients * Watch-history reads, logging, date editing, and deletion * Ratings, favourites, planning, and watch-status reads and writes * User list CRUD and list-item management * Physical and digital collection reads and writes * Calendar and Continue Watching endpoints * Public documentation for all 12 developer-app scopes ### Updated * Native clients can now implement the core PunchPlay movie and TV experience without linking to the web application * The OpenAPI reference now covers the complete `/api/platform/v1` route tree * OAuth guidance now recommends authorization code + PKCE for native apps that can receive a callback * The reserved `email:read` scope is no longer offered to developer apps ### Current boundary * Notifications, trophies, and account settings remain web experiences * Community endpoints are used by the first-party mobile client but are not yet part of the stable `/api/platform/v1` contract * Anime discovery was not yet included in that mobile-client release ## 2026-06-16 ### Added * Persistent platform request logging keyed by `request_id` for support and incident investigation * Open developer app registration in PunchPlay settings for confidential and public clients ### Updated * OAuth refresh responses now rotate and return a new `refresh_token` with `refresh_expires_in` * Device refresh responses now rotate and return a new `refresh_token` with `refresh_expires_in` * Playback and auth docs now document the current debugging and token rotation contract * OpenAPI reference now reflects the live refresh-token response shape ## 2026-06-09 ### Added * Authorization code + PKCE support for PunchPlay developer apps * App-level `allowed_scopes` policy for developer app registration * Request-scoped platform error IDs via `request_id` and `X-PunchPlay-Request-Id` * Scope enforcement updates across `/me`, playback, device auth, and OAuth token flows * Connected app revocation support in user settings ### Updated * Authentication docs now cover both OAuth and device code flows * Quickstart now reflects web, browser, native, desktop, and TV integration paths * Error docs now describe the current debugging and support contract * OpenAPI reference now reflects the current platform auth surface ## 2026-06-08 ### Added * Initial PunchPlay developer portal * Platform API documentation for device authentication * Platform playback documentation for `start`, `pause`, `resume`, `stop`, and `progress` * Live playback guides for now-playing, in-progress, and SSE updates * Public API documentation for public user profile data * Generated endpoint reference from the initial OpenAPI definition * Developer app registration with `client_id` issuance * Confidential and public client support * Scope-aware platform tokens ### Notes * The current public platform contract is `v1`. * Future work is expected in areas such as app registration automation, outbound webhooks, and richer read and write surfaces. # Data Conventions Source: https://docs.punchplay.tv/data-conventions Field names, timestamps, identifiers, progress units, nulls, and pagination used by PunchPlay APIs. Use the operation's generated schema as the authority. PunchPlay's Platform API has a few deliberate naming differences between playback/bulk requests and native library routes; converting every field with one global rule will produce invalid requests. ## Field names | Area | Examples | Convention | | - | - | - | | Playback request | `event_id`, `event_created_at`, `position_seconds`, `tmdb_id` | `snake_case` | | Bulk sync request | `client_item_id`, `watched_at`, `resolved_tmdb_id` | `snake_case` | | Native profile/history responses | `watchedAt`, `memberSince`, `nextCursor` | `camelCase` | | Sync response | `resourceId`, `changedAt`, `resetRequired` | `camelCase` | The OpenAPI reference shows the exact request and response shape for each operation. Unknown request fields are rejected by the strict playback and bulk schemas, so copy the documented spelling. ## Time and progress * `event_created_at` is a Unix timestamp in **milliseconds**. It describes the client event and should not be replaced with the retry time. * `watchedAt`, `watched_at`, `changedAt`, and `updatedAt` are ISO 8601 date-time strings, normally rendered with a `Z` suffix for UTC. * `position_seconds` and `duration_seconds` are whole seconds. Keep positions between zero and the duration when a duration is known. * Playback request `progress` is a ratio from `0` to `1` (`0.5` means halfway), never a percentage such as `50`. * Playback read responses expose `progressSeconds`, `durationSeconds`, and `progressPercent` from `0` to `100` (`50` means halfway). ## IDs, kinds, and nulls Title paths use a routable `movie` or `show` kind. Anime is a category and may be represented by the optional `isAnime`/`anime` classification; it does not change a title's path kind. TMDB IDs are numeric. IMDb (`tt...`), TVDB (`tvdb:...`), and MAL (`mal:...`) identifiers are accepted in the documented title paths and bulk items. `null` means the API knows that a value is absent. An omitted optional write field means “use the operation's default” or “leave the current value unchanged,” depending on the endpoint. Read the operation description before using `null` to clear a value; some updates require an explicit nullable field. ## Pagination and cursors Collection endpoints return bounded pages with an opaque `nextCursor`: ```json theme={null} { "items": [], "nextCursor": "opaque-value-or-null" } ``` Send the cursor back unchanged and keep every other query parameter identical. Do not decode, sort, or construct cursor values. Page-number endpoints expose `page`, `pageSize`, `total`, and `hasMore` instead. Public catalog and community endpoints currently have bounded responses and do not accept `page` or `cursor`. The sync change feed keeps a non-null checkpoint in `nextCursor` even when a page has `hasMore: false`; use that field for the next synchronization round, while a null `nextCursor` marks completion only for cursor-paginated lists. The change feed is at least once. Apply a page idempotently, then save its cursor in the same local transaction. A bulk write's `Idempotency-Key` is also opaque: reuse it only for a retry of the same request body. ## Dates and localization Treat API date-time strings as instants and format them in the user's locale at the UI boundary. Calendar queries use the documented `YYYY-MM` month form; do not infer a user's timezone from a browser offset when the endpoint returns an explicit date-time. # Errors & Rate Limits Source: https://docs.punchplay.tv/errors-rate-limits Understand PunchPlay's current developer-facing failure modes and request limits. This page documents the current developer-facing error behavior for the versioned public and platform APIs. ## Platform error shape Platform endpoints return machine-readable errors shaped like this: ```json theme={null} { "error": "insufficient_scope", "message": "This token is missing the required scope: playback:write.", "request_id": "ppreq_1234567890abcdef1234", "required_scope": "playback:write" } ``` Platform responses also include `X-PunchPlay-Request-Id` in the headers. This envelope is consistent across native platform handlers, including errors from the underlying list, history, rating, collection, and profile routes. ## Common HTTP status codes | Status | Meaning | | - | - | | `200` | Request succeeded | | `400` | Invalid request body, parameter, scope request, or unresolved device-code state | | `401` | Missing, invalid, expired, revoked, or mismatched credentials | | `403` | Token does not have access to the requested surface | | `408` | Request body was not received within 10 seconds | | `404` | Unknown route or missing resource | | `409` | Request conflicts with current state or idempotency state | | `413` | Request body exceeded the 64 KiB platform limit | | `422` | A valid playback payload could not be resolved to media | | `429` | Too many requests | | `500` | Temporary server-side failure | ## Common platform error codes | Error | Meaning | | - | - | | `authorization_pending` | User has not approved the device code yet | | `conflict` | The request could not be applied against the current state | | `expired` | Device code is invalid or expired | | `first_party_only` | The endpoint is reserved for an official PunchPlay client | | `idempotency_conflict` | The `Idempotency-Key` was already used with a different request body | | `idempotency_indeterminate` | An earlier request with this key died before recording its result, so whether it applied is unknown | | `invalid_client` | `client_id` or `client_secret` is invalid | | `invalid_grant` | Refresh token or authorization code is invalid, expired, revoked, or already used | | `invalid_request` | Required fields are missing or malformed | | `invalid_scope` | The request asked for a scope outside the app's configured `allowed_scopes` | | `invalid_token` | The bearer token is not allowed to use the platform API | | `insufficient_scope` | The bearer token does not include the scope required by the endpoint | | `not_found` | The requested platform resource does not exist | | `media_not_found` | Playback media identity could not be resolved; correct the identifiers before retrying | | `playback_state_conflict` | The playback action requires an active session, such as `pause`, but none exists | | `rate_limited` | The request exceeded a current limit | | `request_in_progress` | A request with this `Idempotency-Key` is still running | | `server_error` | Temporary server-side failure | | `unauthorized_client` | The app is inactive or unknown for the attempted OAuth flow | | `unsupported_grant_type` | `grant_type` is not supported | | `unsupported_response_type` | `response_type` is not supported | ## Current rate limits These limits describe the prepared `1.0.0-beta.5` contract. Verify the deployed `info.version` before relying on them. JSON and form request bodies accepted by the Platform API are limited to 64 KiB and must arrive within 10 seconds. A body at exactly 64 KiB is accepted. Bulk sync endpoints accept up to 512 KiB, because a 100-item batch can exceed the ordinary ceiling. ### Every authenticated request Two buckets apply to every request that carries a bearer token. The endpoint-specific limits below apply **on top of** these, so the tighter of the two is what you will hit first. | Bucket | Scope | Limit | | - | - | - | | Per app + user | app + user | Independent 300 requests per minute read and 120 per minute write buckets | | Per app | app, across all its users | 6,000 requests per minute | ### Rate limit headers Responses from rate-limited authenticated platform requests report the bucket closest to being exhausted: ```http theme={null} X-RateLimit-Limit: 300 X-RateLimit-Remaining: 297 X-RateLimit-Reset: 1785312000 ``` `X-RateLimit-Reset` is Unix time in seconds. All three are listed in `Access-Control-Expose-Headers`, so browser clients can read them on cross-origin requests. Throttle on `X-RateLimit-Remaining` rather than waiting for a `429`. Because the reported bucket is whichever is closest to empty, honouring it keeps you inside both the per-app-and-user and per-app ceilings without tracking them separately. ### Platform API | Endpoint area | Limit | | - | - | | `POST /api/platform/v1/auth/device/code` | 60 requests per hour per end-user IP; 10 per hour per IP for invalid client or scope requests | | `POST /api/platform/v1/auth/device/token` | 1,200 requests per 10 minutes per end-user IP and 200 per 10 minutes per device code; 200 per 10 minutes per IP for invalid requests | | `POST /api/platform/v1/auth/refresh` (valid token, including a retry within the 60-second grace window) | 20 requests per hour per refresh-token chain, and 1,000 per hour per app-or-user and IP | | `POST /api/platform/v1/auth/refresh` (invalid token, or a replay outside the grace window) | 20 requests per hour per known client and IP, or per IP if no `client_id` is supplied | | `POST /api/platform/v1/oauth/token` | 180 admission attempts per minute per IP, then 120 requests per minute per client and IP | | `GET /api/platform/v1/calendar` | 60 requests per minute per app and user | | `GET /api/platform/v1/me/history` | 120 requests per minute per app and user | | `GET /api/platform/v1/me/lists` | 120 requests per minute per app and user | | `POST /api/platform/v1/playback/{action}` | 120 requests per 5 minutes per user | | `GET /api/platform/v1/playback/events` | 20 connection attempts per minute per token and 60 per minute per user | | `POST /api/platform/v1/oauth/revoke` | 60 requests per minute per client and IP | | `POST /api/platform/v1/oauth/authorize` | 30 approvals per minute per user | | `GET /api/platform/v1/me/sync/changes` | 120 requests per minute per app and user | | `GET /api/platform/v1/me/sync/snapshot` | 120 requests per minute per app and user | | `POST /api/platform/v1/sync/{resource}` | 30 requests per minute per operation, app, and user | ### Public API | Endpoint area | Limit | | - | - | | `GET /api/public/v1/users/{username}` | 60 requests per minute per IP | | `GET /api/public/v1/catalog/search` | 60 requests per minute per IP | | `GET /api/public/v1/catalog/trending` | 60 requests per minute per IP | | `GET /api/public/v1/catalog/discover` | 90 requests per minute per IP | | `GET /api/public/v1/community/overview` | 60 requests per minute per IP | ## Retry behavior Every `429` includes a `Retry-After` header in seconds. Respect that value when retrying; a retry that ignores it consumes budget you have not yet earned back. ## Recommended client behavior * Retry `429` responses after `Retry-After`, and throttle on `X-RateLimit-Remaining` so you rarely reach one. * On `409 request_in_progress`, wait and retry the **same** `Idempotency-Key`. * On `409 idempotency_indeterminate`, do **not** reuse the key. That write may or may not have applied; reconcile through `GET /api/platform/v1/me/sync/changes` and retry with a new key. * Treat `401` bearer-token responses as a signal to refresh or re-authorize. * Treat `403` as a permission problem for that token. * Treat `409 playback_state_conflict` as non-retryable until the client corrects its playback lifecycle. * Treat `422 media_not_found` as non-retryable until the client corrects the media identity. * Treat `400` validation and scope errors as non-retryable until you fix the request. * Treat `408` as retryable only when the client can send the complete body promptly. * Reduce the body before retrying a `413` response. * Do not retry `invalid_client` until you verify app credentials. * Replace refresh tokens immediately when a refresh response returns a new one. * Log `request_id`, endpoint, status code, and raw error body for every failed platform request. ## What to send support If something breaks and you need investigation, send: * `request_id` * UTC timestamp * endpoint path * HTTP status code * `client_id` * raw error body PunchPlay persists request logs keyed by `request_id`, so that information is enough to trace the failure quickly. ## Stability note The platform contract is versioned, but rate limits may evolve as usage patterns become clearer. If your integration depends on sustained high-volume traffic, design for graceful backoff and batching where possible. # Overview Source: https://docs.punchplay.tv/index Build secure apps on top of PunchPlay's versioned platform and public APIs. PunchPlay exposes two API surfaces today: | Surface | Best for | Auth | | - | - | - | | Platform API | Native and third-party apps with account, title, history, library, calendar, and playback access | Bearer token via authorization code + PKCE or device code | | Public API | Public profile embeds, dashboards, bots, and companion widgets | No auth | ## What you can build * Second-screen apps that stay in sync with what a user is watching * Complete movie and TV companion apps with title details, history, ratings, lists, collection, calendar, and Continue Watching * Desktop or TV integrations that report playback lifecycle events * Browser apps and websites that connect a PunchPlay account with OAuth * Public profile widgets, community dashboards, and analytics overlays ## Current platform capabilities * Developer app registration at `https://punchplay.tv/developers` * Confidential and public client types * Per-app `allowed_scopes` * Authorization code + PKCE for web, browser, and native apps with a callback * Device code for TV, desktop, CLI, and clients without a convenient callback * Versioned platform endpoints under `/api/platform/v1` * Scope-aware platform tokens * Title, season, and episode metadata backed by TMDB IDs * Watch-history logging, editing, deletion, ratings, favourites, and watch statuses * User list and collection management * Calendar and Continue Watching reads * Playback writes for `start`, `pause`, `resume`, `stop`, and `progress` * Live playback state via now-playing, in-progress, and SSE updates * Request-scoped error IDs for platform debugging * Read-only public user data under `/api/public/v1` * Public health status at `/api/public/v1/status` ## Start here Register an app, choose an auth flow, and send your first playback event. Choose between OAuth + PKCE and device code, then implement tokens securely. Send start, pause, resume, stop, and progress events from your integration. Build the core iOS or Android experience with titles, history, library, calendar, and playback. No-auth read-only access to public profiles when you do not need user grants. Use the right field names, time units, progress scale, IDs, and cursors. ## Status and versioning All public developer endpoints are versioned. The current Platform API contract is `v1` beta; breaking changes may still be recorded within that beta namespace, so pin and validate the OpenAPI version you integrate. The Public API is stable at `v1`. * Platform endpoints live under `/api/platform/v1` * Stable public endpoints live under `/api/public/v1` * Breaking changes will ship in a new versioned namespace For a contract or integration question, bring the request ID and UTC timestamp to the [PunchPlay Discord](https://discord.gg/pPwct597Ut). Do not post tokens or client secrets. ## Base URL ```text theme={null} https://punchplay.tv ``` ## Need read-only data only? If you only need public profile data, skip the platform auth flow and use the [Public API](/public-api) instead. # Android & TV Integration Source: https://docs.punchplay.tv/kotlin-integration Build a Kotlin client against the PunchPlay platform API, with device authorization, token refresh, and library sync. This example uses `kotlinx.serialization` throughout. It includes the DTOs used by the Retrofit interface, a raw refresh client, a bearer interceptor, and the bulk-history result handling. There are no Moshi annotations in this example. ## Dependencies The following is a small JVM/Android-compatible Gradle setup. Pin the versions in your own dependency catalog and update them as a unit when you upgrade Kotlin: ```kotlin theme={null} plugins { kotlin("jvm") version "2.0.21" kotlin("plugin.serialization") version "2.0.21" } repositories { mavenCentral() } dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") } ``` ## DTOs and API Use `@SerialName` for the API's snake-case fields. Response objects such as `nextCursor`, `resourceId`, and `changedAt` are camelCase, so they keep their Kotlin property names. ```kotlin theme={null} import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Query @Serializable data class DeviceCodeRequest( @SerialName("client_id") val clientId: String, val scope: String, ) @Serializable data class DeviceCodeResponse( @SerialName("user_code") val userCode: String, @SerialName("device_code") val deviceCode: String, @SerialName("verification_uri") val verificationUri: String, @SerialName("verification_uri_complete") val verificationUriComplete: String? = null, @SerialName("verification_uri_qr") val verificationUriQr: String? = null, @SerialName("expires_in") val expiresIn: Long, val scope: String, ) @Serializable data class RefreshRequest( @SerialName("client_id") val clientId: String, @SerialName("refresh_token") val refreshToken: String, ) @Serializable data class Tokens( @SerialName("access_token") val accessToken: String, @SerialName("refresh_token") val refreshToken: String, @SerialName("token_type") val tokenType: String, @SerialName("expires_in") val expiresIn: Long, @SerialName("refresh_expires_in") val refreshExpiresIn: Long, val scope: String, val username: String? = null, ) @Serializable data class SyncChange( val cursor: String, val resource: String, val resourceId: String, val operation: String, val changedAt: String, val data: JsonObject? = null, ) @Serializable data class SyncChanges( val changes: List, val hasMore: Boolean, val nextCursor: String, val resetRequired: Boolean, val resources: List, ) @Serializable data class SyncSnapshot( val items: List, val hasMore: Boolean, val nextAfter: Long? = null, val resource: String, ) @Serializable data class BulkHistoryItem( @SerialName("client_item_id") val clientItemId: String, val kind: String, @SerialName("tmdb_id") val tmdbId: Int? = null, @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tvdb_id") val tvdbId: Int? = null, @SerialName("mal_id") val malId: Int? = null, @SerialName("watched_at") val watchedAt: String, val title: String? = null, val year: Int? = null, val season: Int? = null, val episode: Int? = null, ) @Serializable data class BulkHistoryRequest(val items: List) @Serializable data class BulkMutationResult( @SerialName("client_item_id") val clientItemId: String?, val index: Int, val status: String, val reason: String? = null, @SerialName("resolved_tmdb_id") val resolvedTmdbId: Int? = null, val error: String? = null, ) @Serializable data class BulkMutationResponse(val results: List) interface PunchPlayApi { @POST("api/platform/v1/auth/device/code") suspend fun createDeviceCode(@Body body: DeviceCodeRequest): DeviceCodeResponse @POST("api/platform/v1/auth/refresh") suspend fun refresh(@Body body: RefreshRequest): Tokens @GET("api/platform/v1/me/sync/changes") suspend fun changes( @Query("cursor") cursor: String? = null, @Query("limit") limit: Int = 100, ): SyncChanges @GET("api/platform/v1/me/sync/snapshot") suspend fun snapshot( @Query("resource") resource: String, @Query("after") after: Long = 0, @Query("limit") limit: Int = 100, ): SyncSnapshot @POST("api/platform/v1/sync/history") suspend fun pushHistory( @Header("Idempotency-Key") idempotencyKey: String, @Body body: BulkHistoryRequest, ): BulkMutationResponse } ``` ## Refresh without recursive interception Build two Retrofit clients. The raw client has no bearer interceptor and is used for device authorization and refresh. Only the authenticated client installs the interceptor. This matters because a refresh request must still work when the access token that triggered the refresh is expired. ```kotlin theme={null} interface TokenStorage { fun load(): Tokens fun save(tokens: Tokens) } class TokenStore( private val refreshApi: PunchPlayApi, private val clientId: String, private val storage: TokenStorage, ) { private val mutex = Mutex() private var tokens = storage.load() suspend fun accessToken(): String = mutex.withLock { tokens.accessToken } /** Returns the current token when another request already refreshed it. */ suspend fun refreshAfter(stale: String): String? = mutex.withLock { if (tokens.accessToken != stale) return@withLock tokens.accessToken val next = runCatching { refreshApi.refresh( RefreshRequest(clientId = clientId, refreshToken = tokens.refreshToken), ) }.getOrNull() ?: return@withLock null // Persist before returning: refresh tokens rotate and only the newest // pair stays valid. tokens = next storage.save(next) next.accessToken } } class AuthInterceptor(private val store: TokenStore) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val used = runBlocking { store.accessToken() } val response = chain.proceed(chain.request().withBearer(used)) if (response.code != 401) return response val refreshed = runBlocking { store.refreshAfter(used) } ?: return response response.close() return chain.proceed(chain.request().withBearer(refreshed)) } } private fun Request.withBearer(token: String): Request = newBuilder() .header("Authorization", "Bearer $token") .build() private val json = Json { ignoreUnknownKeys = true } private val jsonMediaType = "application/json".toMediaType() private fun retrofit(client: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl("https://punchplay.tv/") .client(client) .addConverterFactory(json.asConverterFactory(jsonMediaType)) .build() data class ApiClients( val raw: PunchPlayApi, val authenticated: PunchPlayApi, val tokens: TokenStore, ) fun buildApiClients(clientId: String, storage: TokenStorage): ApiClients { val raw = retrofit(OkHttpClient()).create(PunchPlayApi::class.java) val tokens = TokenStore(raw, clientId, storage) val authenticated = retrofit( OkHttpClient.Builder() .addInterceptor(AuthInterceptor(tokens)) .build(), ).create(PunchPlayApi::class.java) return ApiClients(raw, authenticated, tokens) } ``` Call `clients.raw.refresh(...)` only through `TokenStore`; call protected sync methods on `clients.authenticated`. Retry a `401` once. If the replay fails, send the user through device authorization again. Because refresh tokens rotate, the mutex and the raw refresh client are both required. ## Initial sync and cursor ownership Keep one encrypted token record, mirror, and cursor per PunchPlay account and app. A cursor is also scoped by the read permissions available to that token. If the user later grants additional read scopes, request a new cursor and snapshot the newly visible resources; an older cursor is not a backfill for data that was previously hidden. ```kotlin theme={null} interface MirrorStore { fun transaction(block: () -> T): T fun replaceResource(resource: String, items: List) fun applyChange(change: SyncChange) fun saveCursor(cursor: String) } private suspend fun replayChanges( api: PunchPlayApi, mirror: MirrorStore, firstPage: SyncChanges, ) { var page = firstPage while (true) { mirror.transaction { page.changes.forEach(mirror::applyChange) mirror.saveCursor(page.nextCursor) } if (!page.hasMore) break page = api.changes(cursor = page.nextCursor) } } suspend fun initialSync(api: PunchPlayApi, mirror: MirrorStore) { val first = api.changes(cursor = null) if (first.resetRequired) { for (resource in first.resources) { val items = buildList { var after = 0L while (true) { val page = api.snapshot(resource = resource, after = after) addAll(page.items) if (!page.hasMore) break after = page.nextAfter ?: error("snapshot page is missing nextAfter") } } // Replace only after all pages are assembled; replacing each page // would discard the rows from earlier pages. mirror.transaction { mirror.replaceResource(resource, items) } } // The first cursor is the snapshot high-water mark. Replay changes that // arrived while the snapshots were running. replayChanges(api, mirror, api.changes(cursor = first.nextCursor)) } else { // With no reset, the first response is already the first page. Do not // skip it by starting a second request from its cursor. replayChanges(api, mirror, first) } } ``` Change delivery is at least once. Apply upserts and tombstones idempotently by `resource` plus `resourceId`, and save the cursor only in the same transaction as the page. A cursor can remain valid for 90 days; after that the API asks for a reset and a fresh snapshot. ## Bulk history with stable idempotency Use one stable `client_item_id` per source watch. Persist one fresh UUID as the `Idempotency-Key` for each logical batch before the first request. If the request times out, retry the same body with the same persisted key. If you change the body, generate and persist a new key. ```kotlin theme={null} data class LocalWatch( val stableId: String, val punchPlayTmdbId: Int?, val imdbId: String?, val isEpisode: Boolean, val watchedAt: String, val season: Int?, val episode: Int?, ) interface WatchDatabase { fun markSynced(watch: LocalWatch, tmdbId: Int?, note: String? = null) fun markRejected(watch: LocalWatch, reason: String) fun markPending(watch: LocalWatch, reason: String) } data class PendingHistoryBatch( val idempotencyKey: String, val watches: List, ) // Persist this batch before the first call. Reuse the same object after a // timeout; do not generate a new key inside the retrying function. suspend fun pushHistory( api: PunchPlayApi, database: WatchDatabase, batch: PendingHistoryBatch, ) { val watches = batch.watches val response = api.pushHistory( idempotencyKey = batch.idempotencyKey, body = BulkHistoryRequest( watches.map { watch -> BulkHistoryItem( clientItemId = watch.stableId, kind = if (watch.isEpisode) "episode" else "movie", tmdbId = watch.punchPlayTmdbId, imdbId = watch.imdbId.takeIf { watch.punchPlayTmdbId == null }, watchedAt = watch.watchedAt, season = watch.season, episode = watch.episode, ) }, ), ) response.results.forEach { result -> val watch = watches.getOrNull(result.index) ?: return@forEach when (result.status) { "inserted", "updated", "removed", "skipped" -> database.markSynced(watch, result.resolvedTmdbId) "deferred" -> database.markSynced(watch, result.resolvedTmdbId, result.reason) "invalid" -> database.markRejected(watch, result.error ?: "invalid item") else -> database.markPending(watch, "unknown bulk status: ${result.status}") } } } ``` `deferred` means PunchPlay stored the item and will retry resolution; do not place it in your retry queue. For `409 request_in_progress`, wait and retry the same key. For `409 idempotency_indeterminate`, reconcile through the change feed and retry the unresolved remainder with a new key. See [Partner Sync](/partner-sync) for scope combinations, identifier formats, and the complete outcome table. # Live Events Source: https://docs.punchplay.tv/live-events Read Continue Watching, now-playing state, in-progress items, and server-sent playback updates. After you send playback writes, use these endpoints to read the latest materialized state. ## Required scopes * `/api/platform/v1/playback/now-playing` requires `playback:read` * `/api/platform/v1/playback/in-progress` requires `playback:read` * `/api/platform/v1/me/continue-watching` requires `playback:read` * `DELETE /api/platform/v1/playback/in-progress/{id}` requires `playback:write` * `/api/platform/v1/playback/events` requires `events:read` ## Now Playing ```bash theme={null} curl https://punchplay.tv/api/platform/v1/playback/now-playing \ -H "Authorization: Bearer ACCESS_TOKEN" ``` Use this when you need the single most relevant active playback item for the user. Common response fields: * `type` * `tmdbId` * `title` * `progressPercent` * `progressSeconds` * `durationSeconds` * `nowPlaying` * `playbackState` * `mediaSource` ## In Progress ```bash theme={null} curl https://punchplay.tv/api/platform/v1/playback/in-progress \ -H "Authorization: Bearer ACCESS_TOKEN" ``` Use this when you need a queue of active or resumable items rather than a single now-playing object. Dismiss an item with: ```bash theme={null} curl -X DELETE https://punchplay.tv/api/platform/v1/playback/in-progress/PROGRESS_ID \ -H "Authorization: Bearer ACCESS_TOKEN" ``` ## Continue Watching ```bash theme={null} curl https://punchplay.tv/api/platform/v1/me/continue-watching \ -H "Authorization: Bearer ACCESS_TOKEN" ``` `/me/continue-watching` returns show-level summaries: the show's current season/episode and completion state. It is useful for a “next episode” row, but it is not the source of an exact movie or episode resume position. Fetch `/api/platform/v1/me/continue-watching/{showId}` when you need the details for a show. Use `/api/platform/v1/playback/in-progress` for resumable movie or episode items with `progressSeconds`, `durationSeconds`, and the progress identifier used by the dismiss endpoint. The `status` field describes the series' lifecycle, not its current release schedule. For example, `Returning Series` means the series may return even after its latest season has finished airing. Do not label it “Airing” based on that status alone. Use `releasePhase` for a schedule-aware badge: * `airing`: a season has released episodes and a dated future episode in that same season * `returning`: a subsequent season is announced, with or without an air date * `upcoming`: an announced first season has not premiered * `season_complete`: released episodes exist but no later episode or season is currently announced * `schedule_pending`: a same-season episode is announced without a future air date, a partly released season has no scheduled next date, or a provider-returning series lacks enough schedule data * `ended`, `canceled`, and `unknown`: terminal provider states or insufficient schedule data This field is best-effort metadata, not a guarantee that a series has been renewed or definitively finished. The `status` response field and `status=returning|airing|ended` query filters keep their provider-lifecycle semantics. Legacy `airing` matches `Returning Series`; legacy `ended` matches every other provider status, not just ended shows. ### Episode pointers and gaps `nextSeason` and `nextEpisode` identify the first **currently available** unwatched episode after `lastWatchedSeason` and `lastWatchedEpisode`. They never rewind to an earlier gap and never point at an unaired episode. Both values are `null` when there is nothing playable after the last-watched episode. Earlier gaps are reported separately: * `earlierUnwatchedSeason` and `earlierUnwatchedEpisode` identify the gap to fill first, or are `null` when there is no gap. That is the earliest aired unwatched episode in a season the user has started; only when every started season is complete up to the last-watched episode does it fall back to the earliest aired unwatched episode overall * `earlierUnwatchedCount` is the number of aired unwatched episodes before the last-watched episode, including seasons the user has not started * `earlierUnwatchedSourceSeason` and `earlierUnwatchedSourceEpisode` give the provider coordinates of that gap for building episode links, like `nextSourceSeason` and `nextSourceEpisode`; `earlierUnwatchedEpisodeTitle` is its title when known A user who starts a show at S03E02 without logging seasons 1–2 has a real gap at S03E01 and two untouched seasons. The pointer names S03E01, and the count includes every episode of seasons 1–2 as well. `nextEpisodeToAir` is a separate announcement pointer. Its `airDate` is optional because an episode can be announced before it is scheduled. A caught-up show remains in Continue Watching while this pointer is present, but clients must not treat it as playable until it becomes the `nextSeason` and `nextEpisode`. For example, if only S01E06 is watched, episodes 1–5 are available, and S01E07 is announced but has not aired, the summary contains: ```json theme={null} { "lastWatchedSeason": 1, "lastWatchedEpisode": 6, "nextSeason": null, "nextEpisode": null, "earlierUnwatchedSeason": 1, "earlierUnwatchedEpisode": 1, "earlierUnwatchedCount": 5, "nextEpisodeToAir": { "season": 1, "episode": 7 } } ``` After S01E07 airs, `nextSeason` and `nextEpisode` advance to `1` and `7`; the earlier-gap fields continue to identify episodes 1–5. ### Current season The `currentSeason*` fields describe the season progress is focused on: `nextSeason` when an episode is playable, otherwise `lastWatchedSeason`. `currentSeasonEpisodesTotal` counts aired episodes only, and `currentSeasonMinutesRemaining` adds up the listed runtimes of that season's unwatched aired episodes. `episodeRuntimeSource` says where `episodeRuntimeMinutes` came from: `history` (the average of runtimes in the user's watch history), `episode` (the next episode's listed runtime), `show` (the provider's series runtime), or `unknown`. ## Server-sent events ```bash theme={null} curl https://punchplay.tv/api/platform/v1/playback/events \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Accept: text/event-stream" ``` The stream sends lightweight notifications whenever playback state changes. Event types: * `ready` * `playback-update` `playback-update` includes: ```json theme={null} { "emittedAt": "2026-06-08T12:00:00.000Z", "sequence": 4 } ``` ## Recommended client pattern Use SSE as an invalidation signal, not as the full source of truth. 1. Open the event stream. 2. Wait for `playback-update`. 3. Fetch `/playback/now-playing` or `/playback/in-progress`. 4. Update your UI from those read endpoints. An SSE connection is authorized when it is opened. Access tokens expire after one hour and revocation can make an existing credential unusable, so track the token expiry, close the stream before it expires, refresh the token, and reconnect. If a connection drops or a refresh/revocation race produces a `401`, refresh or re-authorize before reconnecting. Do not treat an open stream as a permanent authorization grant. ## Browser caveat Standard browser `EventSource` does not let you attach an `Authorization` header. If your app runs in the browser, use one of these approaches: * Proxy the stream through your backend * Use a fetch-based streaming client instead of `EventSource` * Open the SSE stream from a native, desktop, or server environment that supports bearer headers ## Cache behavior Playback read endpoints return live user state and should be treated as non-cacheable application data. ## Failure handling If the SSE connection drops or its handshake returns `503 service_unavailable`, wait for the `Retry-After` delay when one is present (otherwise use bounded exponential backoff) before reconnecting: * fetch `/playback/now-playing` after reconnecting * do not assume missed SSE messages contain the full state you need SSE notifications are lightweight invalidations, not a durable delivery queue. Use the change feed in [Partner Sync](/partner-sync) when a mirror must recover every change, and reconcile with a read endpoint after reconnecting. If your deployment has multiple application processes, verify the event fan-out topology; a process-local listener cannot by itself guarantee cross-process delivery. If the stream handshake fails, capture the `X-PunchPlay-Request-Id` response header when available and log the endpoint, timestamp, and status code. # Native App API Source: https://docs.punchplay.tv/native-app-api Build the core PunchPlay movie and TV experience for iOS, Android, desktop, or TV. The Platform API covers the core native PunchPlay experience: account identity and editing, preferences, title discovery data, seasons and episodes, history, ratings, favourites, watch statuses, lists, collection, calendar, Continue Watching, notifications, trophies, community stats, and live playback. All protected endpoints use a scoped bearer token: ```http theme={null} Authorization: Bearer ACCESS_TOKEN Accept: application/json ``` Title `type` values are `movie` or `show`. Title IDs in paths accept a TMDB numeric id (`550`), or a prefixed IMDb (`tt0137523`), TVDB (`tvdb:81189`), or MAL (`mal:16498`) id — PunchPlay resolves whichever you send to the canonical title server-side. ## Recommended scope set For a full native movie and TV client, request: ```text theme={null} profile:read profile:write playback:read playback:write events:read history:read history:write lists:read lists:write ratings:read ratings:write collection:read collection:write notifications:read notifications:write trophies:read ``` Account email is not exposed to developer apps. Use the stable user ID from `GET /api/platform/v1/me` for account linking. Existing users must approve newly requested scopes and receive a new access token before the corresponding endpoints become available. ## Endpoint map | Client feature | Method and path | Required scope | | - | - | - | | Account and profile | `GET /api/platform/v1/me` | `profile:read` | | Profile settings | `GET` or `PATCH /api/platform/v1/me/profile` | `profile:read` or `profile:write` | | App preferences | `GET` or `PATCH /api/platform/v1/me/preferences` | `profile:read` or `profile:write` | | Community stats | `GET` or `POST /api/platform/v1/me/community-stats` | `profile:read`, `history:read`, `ratings:read` | | Calendar | `GET /api/platform/v1/calendar?month=YYYY-MM` | `profile:read`, `history:read`, `lists:read` | | Title details | `GET /api/platform/v1/title/{type}/{id}` | `profile:read` | | Season details | `GET /api/platform/v1/title/show/{id}/season/{season}` | `profile:read` | | Episode details | `GET /api/platform/v1/title/show/{id}/season/{season}/episode/{episode}` | `profile:read` | | Continue Watching | `GET /api/platform/v1/me/continue-watching` | `playback:read` | | Show progress | `GET /api/platform/v1/me/continue-watching/{showId}` | `playback:read` | | In-progress playback | `GET /api/platform/v1/playback/in-progress` | `playback:read` | | Dismiss progress | `DELETE /api/platform/v1/playback/in-progress/{id}` | `playback:write` | | Playback lifecycle | `POST /api/platform/v1/playback/{action}` | `playback:write` | | Watch history | `GET /api/platform/v1/me/history` | `history:read` | | Log or clear a movie | `POST` or `DELETE /api/platform/v1/title/movie/{id}/history` | `history:write` | | Log or clear episodes | `POST` or `DELETE /api/platform/v1/title/show/{id}/season/{season}/watch` | `history:write` | | Edit or delete one watch | `PATCH` or `DELETE /api/platform/v1/watch-history/{id}` | `history:write` | | Ratings, favourites, statuses | `GET /api/platform/v1/me/ratings`, `/favourites`, or `/watch-status` | `ratings:read` | | Change interaction state | `PATCH /api/platform/v1/title/{type}/{id}/interact` | `ratings:write` | | List summaries and details | `GET /api/platform/v1/me/lists` and `/lists/{id}` | `lists:read` | | Manage lists | `POST /api/platform/v1/lists`; `PATCH` or `DELETE /lists/{id}` | `lists:write` | | Manage list items | `GET` or `POST /lists/{id}/items`; `DELETE /lists/{id}/items/{itemId}` | `lists:read` or `lists:write` | | Collection | `GET /api/platform/v1/me/collection` | `collection:read` | | Manage collection | `POST /api/platform/v1/collection`; `DELETE /collection/{id}` | `collection:write` | | Notifications | `GET /api/platform/v1/me/notifications` and `/unread-count` | `notifications:read` | | Mark notifications read | `POST /api/platform/v1/me/notifications/read` | `notifications:write` | | Notification preferences | `GET` or `PATCH /api/platform/v1/me/notification-preferences` | `notifications:read` or `notifications:write` | | PunchPlay push devices (**first-party only**) | `GET` or `POST /api/platform/v1/me/push-devices`; `DELETE /push-devices/{id}` | `notifications:read` or `notifications:write` | | Trophies | `GET /api/platform/v1/me/trophies` | `trophies:read` | ## Pagination Collection-style reads return a bounded page and an opaque `nextCursor`. Pass that value back as `cursor` to fetch the next page; `null` means you have reached the end. ```json theme={null} { "items": [], "nextCursor": "eyJpZCI6NDJ9" } ``` | Endpoint | Default | Maximum | | - | - | - | | `GET /api/platform/v1/me/history` | 25 | 100 | | `GET /api/platform/v1/me/notifications` | 30 | 100 | | `GET /api/platform/v1/me/collection` | 100 | 200 | | `GET /api/platform/v1/me/lists` | 50 | 100 | Treat cursors as meaningless strings — do not parse or build them — and keep every other query parameter identical while paging through one result set. `GET /api/platform/v1/me/ratings`, `/favourites`, and `/watch-status` use page-number pagination instead, taking `page` and returning `total`, `page`, `pageSize`, and `hasMore`. `GET /api/platform/v1/me/trophies` returns a fixed catalogue and is not paginated. `me/collection` and `me/lists` previously returned the entire set in one response. They are now paged, so a client that reads only `items` will see the first page rather than everything. Follow `nextCursor` if you need the full set. ## Profile settings Read the complete editable profile with `GET /api/platform/v1/me/profile` and the `profile:read` scope. Send only the fields that changed to the same endpoint with `PATCH` and the `profile:write` scope; omitted fields keep their current values. Use this endpoint for portable account details such as `displayName`, `bio`, `avatarUrl`, `isPublic`, regional formats, and spoiler protection. PunchPlay website interface settings are not part of the public contract. ```bash theme={null} curl -X PATCH "https://punchplay.tv/api/platform/v1/me/profile" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"displayName":"Test User","isPublic":true,"spoilerProtection":true}' ``` ## Title, season, and episode details Fetch a movie or show by TMDB ID: ```bash theme={null} curl "https://punchplay.tv/api/platform/v1/title/movie/550" \ -H "Authorization: Bearer ACCESS_TOKEN" ``` The response includes title metadata and returns private interaction fields only when the token also has their corresponding read scopes. Season zero is valid for specials; episode numbers start at one. ## History Log a movie watch: ```bash theme={null} curl -X POST "https://punchplay.tv/api/platform/v1/title/movie/550/history" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Fight Club","year":1999,"watchedAt":"2026-07-18T20:00:00.000Z"}' ``` Log one episode through the season-watch endpoint. Include `allowRewatches: true` only when the user explicitly chooses to log it again: ```bash theme={null} curl -X POST "https://punchplay.tv/api/platform/v1/title/show/100/season/2/watch" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title":"Example Show", "year":2026, "watchedAt":"2026-07-18T20:00:00.000Z", "allowRewatches":true, "episodes":[{"episodeNumber":4,"name":"Episode 4","runtime":48,"airDate":"2026-07-10"}] }' ``` Watch dates cannot be in the future. Use `PATCH /api/platform/v1/watch-history/{id}` with a `watchedAt` body to edit a date, or `DELETE` to remove that entry. ## Ratings, favourites, and watch status One interaction request can change any combination of rating, favourite state, or show status: ```bash theme={null} curl -X PATCH "https://punchplay.tv/api/platform/v1/title/show/100/interact" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"rating":9,"isFavourite":true,"showStatus":"PLANNING"}' ``` Use the optional `scope`, `season`, and `episode` query parameters when the interaction targets a series, season, or episode rather than the title itself. ## Lists Create a private list and add a title: ```bash theme={null} curl -X POST "https://punchplay.tv/api/platform/v1/lists" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Weekend watches","description":"Films for Saturday night","isPublic":false}' curl -X POST "https://punchplay.tv/api/platform/v1/lists/7/items" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"movie","sourceId":550,"title":"Fight Club"}' ``` List names are limited to 100 characters and descriptions to 500. Both reject links and inappropriate language. List summaries include `externalSource`. Treat a non-null value as externally managed and read-only. Dynamic lists are also read-only for item changes. A watchlist cannot be renamed or deleted, but its items can be added and removed. Collaborators can change items on ordinary lists; only the owner can rename or delete the list. For anime, send the normal routable `kind` (`movie` or `show`) and the source identity. `isAnime: true` remains an optional compatibility hint. When alias resolution selects a canonical TVDB identity, PunchPlay derives the anime category automatically even if the hint is omitted. ## Collection Add a physical or digital edition: ```bash theme={null} curl -X POST "https://punchplay.tv/api/platform/v1/collection" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"movie","sourceId":550,"title":"Fight Club","year":1999,"format":"4k_bluray"}' ``` Valid formats are `4k_bluray`, `bluray`, `hd_dvd`, `dvd`, and `digital`. Adding the same title, season, and format again is idempotent and returns the existing item ID. Reading the collection is paged — see [Pagination](#pagination). Follow `nextCursor` when you need the user's whole collection rather than one screen. ## Continue Watching Read show-level “next episode” summaries: ```bash theme={null} curl "https://punchplay.tv/api/platform/v1/me/continue-watching" \ -H "Authorization: Bearer ACCESS_TOKEN" ``` `GET /api/platform/v1/me/continue-watching` and its `{showId}` detail route summarize a show's current season, episode, and completion state. They are not a per-item resume-position feed. Use `GET /api/platform/v1/playback/in-progress` for exact movie or episode positions (`progressSeconds`, `durationSeconds`) and `DELETE /api/platform/v1/playback/in-progress/{id}` when the user dismisses an item. `nextSeason` and `nextEpisode` move forward from the last-watched episode and only identify an episode that has aired. Earlier aired gaps are exposed through `earlierUnwatchedSeason`, `earlierUnwatchedEpisode`, and `earlierUnwatchedCount` instead of making the next pointer jump backward. `nextEpisodeToAir` reports a separately announced future episode; its `airDate` may be absent, and its presence keeps a caught-up show in Continue Watching. For display, use the additive `releasePhase` field to distinguish an actively airing season from an announced future season. The existing `status` field and status query filters retain their provider-lifecycle meanings. See [Episode pointers and gaps](/live-events#episode-pointers-and-gaps) for a worked example. ## Notifications Developer apps can use the notification endpoints for an in-app inbox, unread badge, mark-read actions, and per-user notification preferences. The `/me/push-devices` routes are reserved for first-party PunchPlay clients. Third-party apps must not register their device tokens with PunchPlay; use your own notification delivery service. PunchPlay's native push delivery requires a native iOS build and APNs credentials managed by PunchPlay. ## Current boundary The versioned Platform API covers the core movie, TV, and anime experience, including notifications, trophies, profile editing, preferences, and authenticated community stats. Stable public discovery and community overview routes live under `/api/public/v1`. Password changes, email changes, connected services, imports, and account deletion remain secure web experiences. Anime is a PunchPlay category rather than a routable title kind. Titles retain a `movie` or `show` kind and TMDB identity, while mapped anime series use TVDB enrichment and official episode ordering. See the generated API Reference in the Reference navigation for parameters and response contracts, and [Playback API](/playback-api) for lifecycle behavior. # Partner Sync Source: https://docs.punchplay.tv/partner-sync Mirror a PunchPlay account and write back in bulk, without polling the library endpoints. The library endpoints — history, ratings, collection, lists — are shaped for presentation. Re-downloading them on a timer is expensive for you and for PunchPlay, and it cannot tell you what changed. Partner sync exists for clients that maintain their own copy of an account: a durable change feed to read from, and bulk endpoints to write back through. ## When to use it Use partner sync if you keep a local mirror of a user's library and need to stay in step with it. If you only read a screenful at a time, the ordinary library endpoints with `limit` and `cursor` are simpler and enough. ## Reading: snapshot, then follow the feed The change feed is populated by database triggers, so web activity, imports, provider webhooks, first-party apps, and your own writes all land in one ordered feed per account. Resources are filtered to the read scopes on your token. Call `GET /api/platform/v1/me/sync/changes` with no cursor. Keep the returned `nextCursor`. A new client sees `resetRequired: true`. For every resource named in `resources`, page through `GET /api/platform/v1/me/sync/snapshot?resource=...&after=...&limit=500` until it is exhausted. Call `/me/sync/changes?cursor=RETAINED_CURSOR`. Apply changes in the order returned, and persist each `nextCursor` only after that page has committed locally. If you persist the cursor first and then fail, you have silently skipped changes. `operation: "delete"` with `data: null` is a tombstone — remove the row. If `resetRequired` ever becomes `true`, discard the mirror and repeat the snapshot step. Cursors stay valid for 90 days. A cursor older than that returns `resetRequired: true` rather than an error. Keep the mirror and cursor per PunchPlay account, app, and read-scope set. If a user later grants another read scope, start a new cursor and snapshot the newly visible resource; an older cursor does not backfill history that the earlier token could not read. ### Delivery is at-least-once Apply every change idempotently, keyed on `resource` and `resourceId`. Receiving the same change twice is normal and must be harmless. This is a deliberate trade. A change's id is assigned when it is written, but it only becomes visible when its transaction commits — so two changes can commit out of order. A cursor therefore also records which transactions were still open when it was issued, and the next read re-checks them. The alternative is worse: a change committing late would fall behind the cursor and never be delivered at all, leaving your mirror silently wrong. ## Writing: bulk endpoints | Data | Endpoint | Scope | | - | - | - | | History | `POST /api/platform/v1/sync/history` | `history:write` | | Ratings, favourites, statuses | `POST /api/platform/v1/sync/ratings` | `ratings:write` | | Watchlist additions and removals | `POST /api/platform/v1/sync/watchlist` | `lists:write` | Each request takes up to 100 items within a 512 KiB body. Size batches against the byte limit as well as the item count — a batch of long-titled entries can reach 512 KiB before it reaches 100 items. Responses carry one outcome per submitted index. An `invalid` item does not fail its valid neighbours, so surface the per-item error text rather than treating the whole batch as failed. History items also need a stable `client_item_id`, derived from your source watch event, for example `nuvio-profile-4:history:1735927200:tmdb:550`. It prevents duplicates across separately keyed batches. ## Identifying items Every bulk item carries **at least one** of `tmdb_id`, `imdb_id`, `tvdb_id` or `mal_id`. If IMDb is your canonical identifier, send it directly — there is no need to translate to TMDB first. Sending several ids at once is allowed: `tmdb_id` is the write identity, and the others are cross-checked against evidence already held, at no extra upstream cost. `title` and `year` are optional on history items, and `title` on watchlist items. Anything omitted is hydrated from TMDB, and a write is never lost merely because its metadata could not be fetched. `client_item_id` is **required** on any item identified only by `imdb_id`, `tvdb_id` or `mal_id`. Such an item may be deferred, and a deferred item is banked under that key so a corrected resend updates the same record instead of creating a second one. Rating and watchlist items carrying `tmdb_id` may still omit it — history items must always send it, regardless of which ids accompany them. ## Deferred items An item whose identifiers cannot be resolved yet comes back as `deferred`. It has been accepted and durably stored, and PunchPlay retries it on its own schedule. `BulkMutationResponse` counts these in a `deferred` total. | `status` | `reason` | What to do | | - | - | - | | `deferred` | `unmatched` | Nothing upstream knows this id yet. **Do not resend** | | `deferred` | `resolver_unavailable` | An upstream source was unreachable. **Do not resend** | | `invalid` | — | Malformed, no identifier, or contradictory ids. Fix and resend | Resending a deferred item only re-banks the same record. Treat it as accepted and move on; if a later sync of the same item succeeds outright, the banked copy is cleared automatically, so nothing is applied twice. Applied items carry `resolved_tmdb_id`. Cache it and send it back as `tmdb_id` on later syncs — that removes resolution from the request path entirely and is the single biggest saving available to an IMDb-first client. This no-loss guarantee covers the bulk endpoints. Path-identified writes have no banking and return a retryable `503` when resolution is unavailable. The public partner contract does not currently expose a per-item deferred-status or requeue endpoint. Keep the source `client_item_id` and the accepted response locally, and use the change feed or a later mirror read to observe when the result has materialized. A dashboard for deferred backlog state, retry timing, and terminal outcomes remains a product decision for a future contract. ## Partner ids in paths The same identifiers work anywhere a path takes a title `{id}` — no translation to TMDB first: ``` GET /api/platform/v1/title/movie/tt0137523 POST /api/platform/v1/title/show/tvdb:81189/season/1/watch PATCH /api/platform/v1/title/show/mal:16498/interact ``` | Form | Example | Cost | | - | - | - | | TMDB id | `550` | No lookup | | IMDb id | `tt0137523` | Resolved server-side | | TVDB id | `tvdb:81189` | Resolved server-side | | MyAnimeList id | `mal:16498` | Resolved from local anime links | Three failure modes, and they want different handling: | Status | Meaning | What to do | | - | - | - | | `400 invalid_title_id` | Malformed, or ids that contradict each other | Fix and resend | | `404 title_not_found` | Well formed, but no title matches | Do not retry unchanged | | `503 title_id_unavailable` | Could not resolve right now | **Retry**, honouring `Retry-After` | Path routes do **not** bank. A single path write has no `client_item_id` to key a stored record on, so when resolution fails nothing is kept and the retry is yours to own — which is why an outage here is a `503` rather than the `deferred` bulk sync returns. For anything batch-shaped, or anything you cannot afford to lose, use bulk sync. ## Idempotency Every bulk write requires an `Idempotency-Key` header. Keys are retained for 24 hours, and replaying a key with the same body returns the original result rather than applying it twice. Three responses need different handling, and the difference matters: | Response | Meaning | What to do | | - | - | - | | `409 request_in_progress` | The original request is still running | Wait, retry the **same** key | | `409 idempotency_conflict` | The key was used with a different body | Fix the body, or use a new key | | `409 idempotency_indeterminate` | The original died before recording its result | Reconcile, then use a **new** key | `idempotency_indeterminate` means the write **may or may not** have applied. PunchPlay will not repeat it blindly, because doing so could double-log a user's watch history. Read `/me/sync/changes` to establish what actually landed, then retry the remainder under a new key. Reusing the old key only returns the same error. ## Rate limits Bulk endpoints allow 30 requests per minute per operation, app, and user, and the change and snapshot reads allow 120 per minute per app and user. The per-token and per-app buckets in [Errors & Rate Limits](/errors-rate-limits) apply on top of those. Throttle on `X-RateLimit-Remaining` and honour `Retry-After` on `429`. For support, retain the request ID, UTC timestamp, endpoint, status, and error body. Ask questions in the [PunchPlay Discord](https://discord.gg/pPwct597Ut) without sharing credentials. # Playback API Source: https://docs.punchplay.tv/playback-api Send playback lifecycle events into PunchPlay and keep user state in sync. The playback API is the core of the PunchPlay platform. It accepts playback lifecycle events and turns them into now-playing state, in-progress items, playback sessions, and watch history. This surface requires the `playback:write` scope. A `stop` that resolves as watched and creates history also requires `history:write`. ## Supported actions Send these actions to: ```text theme={null} POST /api/platform/v1/playback/{action} ``` Available actions: * `start` * `pause` * `resume` * `stop` * `progress` ## Minimum payload shape Every playback request must include deterministic event and session identifiers, a client event timestamp, and enough media identity to resolve the title. ```json theme={null} { "event_id": "evt_123", "event_created_at": 1780910400000, "media_type": "movie", "title": "Fight Club", "year": 1999, "tmdb_id": 550, "playback_session_id": "player-session-123" } ``` ## Supported payload fields | Field | Type | Notes | | - | - | - | | `event_id` | string | Required unique identifier for idempotent event processing | | `media_type` | `movie` or `episode` | Required | | `title` | string | Required | | `year` | number | Helpful for movie disambiguation | | `imdb_id` | string | Optional external identifier | | `tmdb_id` | number | Preferred metadata identifier | | `tvdb_id` | number | TVDB identifier used for mapped anime-series enrichment | | `punchplay_id` | string | PunchPlay-native identifier when available | | `season` | number | Required for episode playback; specials use season `0` | | `episode` | number | Required for episode playback | | `episode_end` | number | End episode for multi-episode playback | | `absolute_episode` | number | Absolute numbering resolved against TVDB anime episode ordering | | `episode_title` | string | Optional episode title | | `multi_episode` | boolean | Indicates multi-episode playback | | `anime` | boolean | Classifies playback as anime without changing `media_type` | | `progress` | number | Completion ratio between `0` and `1`; never send a percentage such as `20` | | `duration_seconds` | number | Total runtime; required when `progress` supplies the resume position | | `position_seconds` | number | Authoritative resume position; required for `pause`, `progress`, and incomplete `stop` unless `progress` and `duration_seconds` are supplied | | `device_id` | string | Client or device identity | | `playback_session_id` | string | Required stable identifier for ordering and session lifecycle | | `event_created_at` | number | Required Unix timestamp in milliseconds; cannot be more than five minutes in the future | | `client_version` | string | Integration version for diagnostics | | `watched` | boolean | Explicit watch completion override on stop | | `watched_threshold` | number | Completion threshold greater than `0` and at most `1` | | `raw_filename` | string | Helps fallback matching | ## Movie example ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/playback/progress \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "event_id": "evt_123", "media_type": "movie", "title": "Fight Club", "year": 1999, "tmdb_id": 550, "position_seconds": 1800, "duration_seconds": 8340, "progress": 0.2158, "device_id": "living-room-mac", "playback_session_id": "player-session-123", "event_created_at": 1780910400000 }' ``` ## Episode example ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/playback/stop \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "event_id": "evt_456", "media_type": "episode", "title": "Severance", "tmdb_id": 95396, "season": 1, "episode": 2, "episode_title": "Half Loop", "position_seconds": 3000, "duration_seconds": 3200, "watched_threshold": 0.9, "playback_session_id": "session-episode-456", "event_created_at": 1780910460000 }' ``` ## Event ordering behavior PunchPlay applies session-aware event handling so clients can send real playback events without manually reconstructing user state. Important behaviors: * Duplicate `event_id` values are deduped. * A `stop` may create a completed watch or only save progress, depending on completion state. * Older events that arrive after a stop are ignored. * A newer incomplete `stop` can update an existing passive snapshot when it uses the same `playback_session_id`, a new `event_id`, and a later `event_created_at`. * Newer progress after a stopped session can reopen the session. * `pause` updates an active session without creating a watch. A standalone pause returns `409 playback_state_conflict`. ## Passive resume sync When importing an existing resume position from another tracker or media server, send one incomplete `stop`. No preceding `start`, `progress`, or `pause` request is required. PunchPlay stores the item as inactive resumable progress, so it appears in Continue Watching without appearing as now playing. ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/playback/stop \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "event_id":"crosswatch-resume:movie:550:1800", "media_type":"movie", "title":"Fight Club", "tmdb_id":550, "position_seconds":1800, "duration_seconds":8340, "watched":false, "playback_session_id":"crosswatch:movie:550", "event_created_at":1780910400000 }' ``` Do not send `progress` followed by `pause` for a passive import. `progress` represents active playback and may briefly publish a now-playing state. A standalone `pause` is a transition for an existing live session and is rejected. To refresh a passive resume position, reuse the stable `playback_session_id`, generate a new `event_id`, and send a later `event_created_at`. PunchPlay compares provider event time rather than arrival time, so delayed older snapshots cannot overwrite newer progress. ## Best practices * Always send a stable `playback_session_id`. * Generate a unique `event_id` for each logical playback update and reuse it only when retrying that same event. * Send the original client event time in `event_created_at`, not the retry time. * Send `tmdb_id` whenever available. * Include `position_seconds` and `duration_seconds` for accurate progress. * Keep `progress` in the `0` to `1` range. PunchPlay can derive `position_seconds` from `progress` only when `duration_seconds` is present. ## Completion rules A `stop` event is treated as watched when one of these is true: * `watched` is explicitly `true` * `progress` or derived progress meets `watched_threshold` If a stop does not qualify as watched, PunchPlay stores progress instead of creating a completed watch entry. When a stop does qualify as watched, the token must include `history:write`. Request that scope during authorization for clients that are allowed to create completed watch history. ## Failure handling Platform playback errors include: * `request_id` in the JSON body * `X-PunchPlay-Request-Id` in the response headers Capture that value when reporting failed writes or invalid payloads. | Status | Error | Meaning | | - | - | - | | `400` | `invalid_request` | Required fields are missing, malformed, unsupported, or outside their allowed range | | `409` | `playback_state_conflict` | The action requires lifecycle state that does not exist, such as a standalone pause | | `422` | `media_not_found` | PunchPlay could not resolve the supplied media identity | | `500` | `server_error` | Playback processing failed unexpectedly; retry with backoff and retain the request ID | # Public API Source: https://docs.punchplay.tv/public-api Read public PunchPlay profile data without authentication. The public API is read-only and supports public profiles, catalog discovery, and community overview experiences. ## Endpoints ```text theme={null} GET /api/public/v1/users/{username} GET /api/public/v1/catalog/search?q={query}&type={movie|show|anime} GET /api/public/v1/catalog/trending?type={movie|show|anime} GET /api/public/v1/catalog/discover?category={popular|now_playing|upcoming|top_rated}&type={movie|show|anime} GET /api/public/v1/community/overview GET /api/public/v1/status ``` ## What it returns The response contains: * public profile basics * watch stats * recent watch history Catalog title cards expose a routable `type` of `movie` or `show`, plus `category` and `isAnime` classification fields. Anime discovery uses TMDB base identities; mapped series receive TVDB enrichment and official episode ordering through authenticated title and playback APIs. Public profile responses may also contain the existing online, donor, donation, subscription, and XP fields shown in the response schema. They are public profile attributes, not authentication signals; display only the fields your feature needs and direct users to PunchPlay's current profile-visibility and privacy controls for questions about their meaning. ## When to use it Use the public API for: * profile widgets * public dashboards * bots * companion overlays that do not need private user authorization * native search, trending, and discovery screens * public community dashboards Do not use it for: * live playback state * app-to-user authenticated actions * writing watches, ratings, or playback updates ## Query parameters | Parameter | Description | Default | | - | - | - | | `historyLimit` | Number of recent watch-history entries to return | `8` | Allowed range: `1` to `20`. Catalog and community responses are bounded by the current public contract. These endpoints do not currently accept `page` or `cursor`; do not present a single response as a complete catalog. Use the documented category and type filters, and treat a future pagination field as an additive contract change. ## Example request ```bash theme={null} curl "https://punchplay.tv/api/public/v1/users/scott?historyLimit=5" ``` ## Example response ```json theme={null} { "version": "v1", "profile": { "username": "scott", "displayName": "Scott", "bio": "Movies, anime, and TV.", "avatarUrl": "https://...", "isOnline": false, "isDonor": false, "donationCount": 0, "isSubscriber": false, "xp": 1200, "memberSince": "2026-05-01T10:20:30.000Z" }, "stats": { "watched": 184, "topGenre": "Drama" }, "recentWatchHistory": [] } ``` ## Behavior * No authentication required * Only public profiles are returned * CORS is enabled * Requests are rate limited per IP `GET /api/public/v1/status` does not require authentication and reports the public API/database health view. It is suitable for an integration status page, but it is not a substitute for your own network or DNS monitoring. ## Error responses | Status | Meaning | | - | - | | `400` | Invalid username or query parameter | | `404` | User not found or profile is not public | | `429` | Rate limit exceeded | ## Platform API vs Public API | Need | Use | | - | - | | Live playback writes | Platform API | | Authenticated app integrations | Platform API | | Public read-only profile data | Public API | # Quickstart Source: https://docs.punchplay.tv/quickstart Register a developer app, authorize a user, and send your first playback event. This guide walks through the smallest useful integration: 1. Create a developer app and get your credentials 2. Choose the right auth flow and scopes for your app 3. Get an access token and verify the identity 4. Send a complete playback lifecycle 5. Read playback state 6. Refresh the access token when needed ## Choose scopes for the job Request only what the integration needs. These recipes assume the app is registered with the listed scopes in `allowed_scopes`: | Integration | Scopes | | - | - | | Public profile or catalog widget | No Platform token; use the [Public API](/public-api) | | Playback tracking without completed watches | `profile:read playback:read playback:write` (add `events:read` for SSE) | | Playback tracking that can complete and verify watches | `profile:read playback:read playback:write history:read history:write` (add `events:read` for SSE) | | Library synchronization | `profile:read history:read history:write ratings:read ratings:write lists:read lists:write collection:read collection:write playback:read`; omit resource groups you do not mirror | `playback:write` lets an app send lifecycle events. A `stop` that resolves as a completed watch also needs `history:write`; ask for that scope before the user authorizes the app rather than discovering the requirement after playback has started. ## Before you begin You need: * A PunchPlay account * A developer app from [Get API key](https://punchplay.tv/developers) * Your app's `client_id` * Your app's `client_secret` if you created a confidential client ## 1. Create a developer app Sign in to [Get API key](https://punchplay.tv/developers), then create either: * A confidential client for server-backed apps that can safely store a secret * A public client for native or browser-based apps that must not embed a secret Also: * register your redirect URIs if you plan to use OAuth * choose the minimum `allowed_scopes` your app should ever request * save the secret immediately if you created a confidential client ## 2. Choose your auth flow Use: * authorization code + PKCE for browser, website, and native apps that can receive a callback * device code for TV, desktop, CLI, and clients without a convenient browser callback ## 3A. Apps with callbacks: authorization code + PKCE Redirect the user to: ```text theme={null} https://punchplay.tv/api/platform/v1/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fpunchplay%2Fcallback&code_challenge=YOUR_PKCE_CHALLENGE&code_challenge_method=S256&scope=profile%3Aread%20playback%3Aread%20playback%3Awrite%20history%3Aread%20history%3Awrite&state=YOUR_STATE ``` After PunchPlay redirects back with `code`, exchange it for tokens: ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "code=AUTHORIZATION_CODE" \ -d "redirect_uri=https://example.com/auth/punchplay/callback" \ -d "code_verifier=YOUR_ORIGINAL_PKCE_VERIFIER" ``` Third-party clients use this redirect-based flow to let PunchPlay's signed-in consent UI collect the decision. Do not call the internal consent-submission POST yourself; the API reference labels it for PunchPlay's own same-origin UI. ## 3B. Apps without callbacks: device code Request a device code: ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/auth/device/code \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "scope": "profile:read playback:read playback:write history:read history:write events:read" }' ``` Public clients send `client_id` only. Confidential clients send both `client_id` and `client_secret`. The example requests the scopes needed for a complete playback lifecycle, including a completed-watch `stop`. A read-only widget or a tracker that never creates completed history can use the smaller recipes above. A full native client normally requests the scope set documented in the [Native App API](/native-app-api#recommended-scope-set). Example response: ```json theme={null} { "user_code": "ABCD-1234", "device_code": "raw_device_code", "verification_uri": "https://punchplay.tv/link", "verification_uri_complete": "https://punchplay.tv/link?code=ABCD-1234&type=platform", "verification_uri_qr": "data:image/png;base64,...", "expires_in": 600, "scope": "profile:read playback:read playback:write history:read history:write events:read" } ``` Display the `user_code` or open `verification_uri_complete` for the user, then poll: ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/auth/device/token \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "device_code": "raw_device_code", "device_name": "PunchPlay Companion" }' ``` While approval is pending, the API returns: ```json theme={null} { "error": "authorization_pending", "message": "Device code has not been approved yet.", "request_id": "ppreq_..." } ``` Once approved, both auth flows produce bearer credentials shaped like this: ```json theme={null} { "access_token": "access_token", "refresh_token": "refresh_token", "token_type": "bearer", "expires_in": 3600, "refresh_expires_in": 31536000, "scope": "profile:read playback:read playback:write history:read history:write events:read" } ``` ## 4. Check the authenticated identity Before writing playback, verify that the token belongs to the account the user just authorized and that the expected scopes were granted: ```bash theme={null} curl https://punchplay.tv/api/platform/v1/me \ -H "Authorization: Bearer ACCESS_TOKEN" ``` The response includes the stable user ID and the granted `scopes` array. Stop and re-authorize if the required scope is missing. ## 5. Send a complete playback lifecycle The following shell variables create fresh UUIDs and timestamps for one walkthrough. Reuse an `event_id` only when retrying that same logical event; generate a new one for each later event. `event_created_at` is Unix time in milliseconds and must describe when the client event occurred. ```bash theme={null} SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" START_EVENT_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" STARTED_AT_MS="$(node -p 'Date.now()')" ``` Start the movie: ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/playback/start \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "event_id": "'"$START_EVENT_ID"'", "event_created_at": '"$STARTED_AT_MS"', "media_type": "movie", "title": "Fight Club", "year": 1999, "tmdb_id": 550, "position_seconds": 0, "duration_seconds": 8340, "playback_session_id": "'"$SESSION_ID"'", "device_id": "macbook-air-living-room" }' ``` Send progress while the user is watching: ```bash theme={null} PROGRESS_EVENT_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" PROGRESS_AT_MS="$(node -p 'Date.now()')" curl -X POST https://punchplay.tv/api/platform/v1/playback/progress \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "event_id": "'"$PROGRESS_EVENT_ID"'", "event_created_at": '"$PROGRESS_AT_MS"', "media_type": "movie", "title": "Fight Club", "year": 1999, "tmdb_id": 550, "position_seconds": 3600, "duration_seconds": 8340, "progress": 0.4317, "playback_session_id": "'"$SESSION_ID"'", "device_id": "macbook-air-living-room" }' ``` While the session is active, read the materialized now-playing state: ```bash theme={null} curl https://punchplay.tv/api/platform/v1/playback/now-playing \ -H "Authorization: Bearer ACCESS_TOKEN" ``` An active response includes fields like these: ```json theme={null} { "type": "movie", "tmdbId": 550, "title": "Fight Club", "year": 1999, "progressPercent": 43.17, "progressSeconds": 3600, "durationSeconds": 8340, "updatedAt": "2026-09-05T06:41:00.000Z", "nowPlaying": true, "playbackState": "watching", "mediaSource": "tmdb" } ``` When the user reaches the end, send a fresh `stop` event. With `history:write`, this creates the completed watch record: ```bash theme={null} STOP_EVENT_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" STOP_AT_MS="$(node -p 'Date.now()')" curl -X POST https://punchplay.tv/api/platform/v1/playback/stop \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "event_id": "'"$STOP_EVENT_ID"'", "event_created_at": '"$STOP_AT_MS"', "media_type": "movie", "title": "Fight Club", "year": 1999, "tmdb_id": 550, "position_seconds": 8340, "duration_seconds": 8340, "watched": true, "playback_session_id": "'"$SESSION_ID"'", "device_id": "macbook-air-living-room" }' ``` Expected response: ```json theme={null} { "ok": true, "media_type": "movie", "tmdb_id": 550, "tvdb_id": null, "imdb_id": "tt0137523", "punchplay_id": "tmdb:movie:550" } ``` ## 6. Confirm the post-stop state After a completed stop, the item is no longer active. Read the resumable queue to confirm that no incomplete progress remains for this session: ```bash theme={null} curl https://punchplay.tv/api/platform/v1/playback/in-progress \ -H "Authorization: Bearer ACCESS_TOKEN" ``` If you query `playback/now-playing` after the completed stop, the response may contain no active item. Use `GET /api/platform/v1/me/history` with `history:read` when you need to verify the completed watch record. ## 7. Refresh the access token when needed Web and browser apps can refresh through the OAuth token endpoint: ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "refresh_token=REFRESH_TOKEN" ``` Successful refresh returns a rotated refresh token. Replace the old one immediately. ```json theme={null} { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "bearer", "expires_in": 3600, "refresh_expires_in": 31415926, "scope": "profile:read playback:read playback:write history:read history:write" } ``` Device-code clients can refresh here: ```bash theme={null} curl -X POST https://punchplay.tv/api/platform/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{ "client_id":"YOUR_CLIENT_ID", "client_secret":"YOUR_CLIENT_SECRET", "refresh_token":"REFRESH_TOKEN" }' ``` Successful refresh returns a rotated refresh token. Replace the old one immediately. ```json theme={null} { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "bearer", "expires_in": 3600, "refresh_expires_in": 31415926, "scope": "profile:read playback:read playback:write history:read history:write events:read" } ``` ## Common errors * `authorization_pending`: the user has not approved the device code yet * `invalid_scope`: the request asked for a scope outside the app policy * `invalid_client`: the provided app credentials are wrong * `insufficient_scope`: the token cannot access the endpoint you called * `429`: you are polling or sending events too aggressively Platform failures include `request_id` in the body and `X-PunchPlay-Request-Id` in the headers. Log that value so you can send it to support. ## Test safely The current developer API does not provide a separate sandbox host. Create one PunchPlay account and one developer app dedicated to integration testing. Keep their tokens outside source control, use a known real fixture such as Fight Club (`tmdb_id: 550`) rather than inventing catalogue data, and clean up reversible list, collection, preference, and dismissed-progress writes in a `finally` path. A completed history write can award XP and playback can leave session/audit records, so run the full start-to-stop example only on that isolated account. For help, bring the UTC timestamp, endpoint, status, response body, and request ID to [PunchPlay Discord](https://discord.gg/pPwct597Ut). ## Next steps * Read [Authentication](/authentication) for both auth flows, token lifecycle, and security guidance. * Read [Native App API](/native-app-api) for title, history, ratings, lists, collection, calendar, and Continue Watching endpoints. * Read [Playback API](/playback-api) for payload design, event ordering, and multi-episode behavior. * Read [Live Events](/live-events) to subscribe to playback changes in real time.