REST API

API Reference

The iOS app runs on this JSON API, and so can anything else you write. Point the playground at your own server to send a real request to every endpoint below.

Registration, login and token refresh are public. Every other /api/v1 endpoint needs an Authorization: Bearer access token. Run Login below and the playground copies the access token into the field here. The one exception is the Jellyfin webhook. It takes the Jellyfin token from the setup guide in the same header.

Requests go straight from your browser, so your server's CORS policy (CLIENT_URL) has to allow this origin. Use HTTPS for a remote server: this page is served over HTTPS, and browsers block plain HTTP requests from it. Loopback addresses are the exception, though the details vary by browser.

Health

GET /live

Liveness probe. No authentication required.

Example response 200
{ "result": "alive" }
GET /ready

Readiness probe — 503 until Postgres answers. No authentication required.

Example response 200
{ "result": "ready" }
GET /health

Plain-text heartbeat, answered by middleware ahead of the router. Accepts HEAD as well as GET. No authentication required.

Example response 200
.

Users

POST /api/v1/users/registrations

Create a new account.

POST /api/v1/users/sessions

Log in. After a successful login the playground copies the access token into the Token field above.

Example response 200
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}
POST /api/v1/users/tokens

Exchange a refresh token for a new token pair.

Example response 200
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}

Account

GET /api/v1/accounts/me

The authenticated user.

PATCH /api/v1/accounts

Update profile and appearance preference.

GET /api/v1/accounts/stats

Library counts, watch-time totals and watch activity for the selected period.

week, month and year mean the current calendar week, month and year, not a rolling window. Inside one of them, want counts what was added rather than the whole list, and the watching and playing counts are omitted. Defaults to all.

Example response 200
{
  "period": "all",
  "movies": { "want": 131, "watched": 895, "minutes": 104760 },
  "series": { "want": 16, "watching": 75, "watched": 96 },
  "episodes": { "watched": 4213, "minutes": 168520 },
  "games": { "want": 12, "playing": 2, "played": 34, "minutes": 61200 },
  "activity": [
    { "date": "2025-01-01", "movieMinutes": 12480, "tvMinutes": 41320 },
    { "date": "2026-01-01", "movieMinutes": 9260, "tvMinutes": 33970 }
  ]
}

Integrations

POST /api/v1/accounts/integrations/jellyfin

Create a Jellyfin token for the current user. A second call replaces the first token.

The token is shown once. The API stores only its SHA-256. It can add and remove watched marks in your library, so treat it as a password. The setup guide explains where it goes.

Example response 201
{
  "token": "3f9c2b1e8d7a6c5b4e3f2a1d0c9b8a7f..."
}
DELETE /api/v1/accounts/integrations/jellyfin

Revoke the Jellyfin token. Events carrying it get 401 from then on. Also 204 when there was no token.

POST /api/v1/webhooks/jellyfin

The endpoint the Jellyfin Webhook plugin posts to. It takes the Jellyfin token, not the access token. A Jellyfin token can add and remove watched marks in its owner's library, so this page never sends it. Try the endpoint from your own machine against your own server.

Played true with SaveReason PlaybackFinished or TogglePlayed adds a watched mark. Played false with SaveReason TogglePlayed takes one away. Every other save is dropped. A movie resolves through Provider_tmdb, an episode through Provider_tvdb or Provider_imdb. A title that is not in the library, or is already in the state the event asks for, is ignored with 204. A title biinge cannot resolve gets 422, a TMDB failure 502 and a database failure 503. Every accepted event is stored with its status.

curl
curl -X POST <your API base>/api/v1/webhooks/jellyfin \
  -H "Authorization: Bearer <Jellyfin token>" \
  -H "Content-Type: application/json" \
  -d '{
    "ItemType": "Episode",
    "SaveReason": "PlaybackFinished",
    "Played": true,
    "Provider_tvdb": "3254641",
    "Provider_imdb": "tt1480055",
    "Name": "Winter Is Coming"
  }'

Movies

GET /api/v1/movies

Your movie library, one state at a time. type defaults to want.

Example response 200
{
  "data": [
    {
      "id": 693134,
      "title": "Dune: Part Two",
      "posterPath": "/czembW0Rk1Ke7lCJGahbOhdCLZ.jpg",
      "pinned": true,
      "state": "want"
    }
  ],
  "meta": { "page": 1, "per": 24, "total": 1 }
}
POST /api/v1/movies

Add a movie to the library. id is the TMDB movie id.

GET /api/v1/movies/{id}

Movie details, credits, and recommendations.

PATCH /api/v1/movies/{id}

Change state or pin the movie.

DELETE /api/v1/movies/{id}

Remove the movie from the library.

Games

GET /api/v1/games

Your game library, one state at a time. type defaults to want.

POST /api/v1/games

Add a game to the library. id is the IGDB game id, and posterPath is an IGDB cover image_id rather than a path.

GET /api/v1/games/{id}

Game details from IGDB, with similar games and IGDB's estimated time for a main playthrough and for 100% completion, both in minutes.

PATCH /api/v1/games/{id}

Change state or pin the game.

DELETE /api/v1/games/{id}

Remove the game from the library.

TV Series

GET /api/v1/series

Your series library, one state at a time. type defaults to want.

POST /api/v1/series

Add a show to the library. id is the TMDB series id.

status is TMDB's own, and it matters: a show left at Returning Series never derives to watched, however many episodes are ticked.

GET /api/v1/series/{id}

Series details with seasons.

PATCH /api/v1/series/{id}

Change state or pin the show.

DELETE /api/v1/series/{id}

Remove the show from the library.

GET /api/v1/series/{id}/season/{seasonNumber}

Season details and episodes.

GET /api/v1/series/{id}/season/{seasonNumber}/episode/{episodeNumber}

Single episode details.

Progress

GET /api/v1/series/{id}/progress

Watched seasons and episodes for a show.

POST /api/v1/series/{id}/watched

Mark a show watched. Ids come from the URL, and the body carries the rows to write.

Only the seasons and episodes you list are recorded, so a body without them marks nothing. The example lists two episodes of season one; a real client sends all nine, and every other season too.

DELETE /api/v1/series/{id}/watched

Unmark the whole show, dropping every season and episode recorded for it.

POST /api/v1/series/{id}/seasons/{seasonId}/watched

Mark a season watched.

The episodes array is what actually gets recorded. Send the season's episodes in it — without them the show ends up with no watched episodes, and nothing is tracked.

DELETE /api/v1/series/{id}/seasons/{seasonId}/watched

Unmark a season and its episodes.

POST /api/v1/series/{id}/seasons/{seasonId}/episodes/{episodeId}/watched

Mark an episode as watched.

The show and season rows are created from this body if they don't exist yet, so send their metadata alongside the episode.

DELETE /api/v1/series/{id}/seasons/{seasonId}/episodes/{episodeId}/watched

Unmark an episode.

Search & Trending

GET /api/v1/search/movies

Search movies. query is required.

GET /api/v1/search/series

Search TV shows. query is required.

GET /api/v1/search/games

Search PlayStation 5 and PlayStation 4 games. query is required.

GET /api/v1/search/people

Search people. query is required.

Up Next

GET /api/v1/up-next

The next aired episode you haven't watched of each pinned show you're watching, plus released movies and games from your pinned want lists.

Example response 200
{
  "episodes": [
    {
      "seriesId": 125988,
      "seriesTitle": "Silo",
      "seriesPosterPath": "/gMYZZvnkVNTqSVnVCphWbPXwWwb.jpg",
      "id": 7173957,
      "title": "Who Are You?",
      "seasonNumber": 3,
      "number": 1,
      "posterPath": "/nIvrv95n7gukebcCEUeZJDy9iEp.jpg",
      "overview": "Juliette wakes somewhere she was never meant to reach…",
      "runtime": 56,
      "rating": 6.6,
      "airDate": "2026-07-02"
    }
  ],
  "movies": [
    {
      "id": 786892,
      "title": "Furiosa: A Mad Max Saga",
      "posterPath": "/iADOJ8Zymht2JPMoy3R7xceZprc.jpg",
      "overview": "As the world falls, young Furiosa is snatched from the Green Place…",
      "runtime": 149,
      "rating": 7.4,
      "releaseDate": "2024-05-22"
    }
  ],
  "games": [
    {
      "id": 1942,
      "title": "The Witcher 3: Wild Hunt",
      "posterPath": "co1wyy",
      "overview": "A story-driven open world RPG set in a fantasy universe…",
      "runtime": 3000,
      "rating": 9.5,
      "releaseDate": "2015-05-19"
    }
  ]
}

People

GET /api/v1/people/{id}

Person details and notable credits.

Reference

ERR Common errors

A failure inside a controller returns a JSON body with an error message and one of these status codes. Responses from the middleware around it — rate limiting, an unmatched route — are plain text.

StatusMeaningWhen
400Bad RequestMalformed JSON, an unparseable id, or a body that fails validation
401UnauthorizedMissing, invalid or expired access token, or wrong login credentials
409ConflictRegistering a login or email that is already taken
422Unprocessable EntityThe request was well formed but the work failed — including a title the upstream provider doesn't have
429Too Many RequestsMore than 5 requests a minute per IP to the public /users routes, 120 a minute per IP to the webhook, or 120 a minute per user everywhere else
500Internal Server ErrorRegistration failed for a reason the API won't detail
502Bad GatewayTMDB failed while a Jellyfin event was being resolved
503Service UnavailableA dependency is unreachable, from /ready, from the auth middleware or from the Jellyfin webhook

There is no 404: a title that isn't in your library, or that TMDB and IGDB can't resolve, comes back as 422.