# 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.