Browse the docs

API reference

Collectoh API v1: 576 endpoints in 57 areas, drawn from the OpenAPI document your own server publishes.

On this page
  1. Base URL
  2. Authentication
  3. Permissions and scopes
  4. Pagination and filters
  5. Errors
  6. Rate limits
  7. Live events
  8. Webhooks
  9. The OpenAPI document and the client
  10. Versioning
  11. Endpoints by area

The Collectoh web app does everything through this API, so a script can do anything the app can. It is JSON over HTTP, versioned, and described by an OpenAPI 3.1 document that your own server publishes. This page covers what holds for every endpoint; the reference for each one follows.

Base URL

Every path in the reference is relative to /api/v1 on your own server, at the same address and port as the web app:

curl https://media.example.com/api/v1/server/info

GET /server/info and GET /server/health need no sign-in, so they make a good first call. Requests and answers are JSON, except streams, images and files.

Authentication

  • Session cookies. The web app signs in with POST /auth/login and keeps its session in HttpOnly cookies. A request signed in by cookie needs an X-Client-Id header to change anything, and a page on another site cannot set one.
  • Tokens. An app that signs people in posts a username and password to POST /auth/login. It gets an access token, good for 15 minutes, and a refresh token, good for 30 days.
    • Send the access token as Authorization: Bearer ….
    • Trade the refresh token for a new pair at POST /auth/refresh. Each refresh token works once; presenting one twice ends that sign-in.
    • An account with two-factor authentication answers AUTH_2FA_REQUIRED with a challenge, to complete at POST /auth/2fa/verify.
  • API keys. For scripts and integrations: a long-lived key that belongs to an account. It can do at most what that account can, and only within the scopes it was made with.

To create a key:

  1. Open your Account page (the avatar menu, then Settings), find API keys and choose New key.
  2. Name it after what will use it, tick its scopes, and choose when it expires and how many requests a minute it may make.
  3. Choose Create key and copy the key, which starts co_. It is shown only once.

Send it as a bearer token:

curl -H "Authorization: Bearer co_…" https://media.example.com/api/v1/libraries

A key is checked on every request, so revoking it works from the next one (401 AUTH_TOKEN_INVALID); an expired key answers AUTH_TOKEN_EXPIRED. Keys are made by a signed-in person, in the app or with POST /api-keys, never by another key. Child and guest accounts cannot hold them.

Whatever the credential, send X-Client-Id (a stable id for your installation) and X-Client-Name. They are how the server tells devices apart; a play queue, for one, belongs to a device.

Permissions and scopes

Each operation in the reference names the permission it needs (x-permission in the OpenAPI document). A role decides which permissions an account has, and a key's scopes narrow them further.

ScopeAllows
library:viewBrowsing and playing everything the account can see
library:manageCreating, changing and deleting libraries and storages
item:editEditing metadata, artwork, markers and matches
user:manageManaging accounts, invitations and access
settings:manageChanging server settings, themes and webhooks
scan:triggerStarting and cancelling scans
session:manageSeeing and ending anyone's playback sessions
job:manageRetrying and cancelling background jobs
download:ownMaking and fetching offline copies
playlist:ownCreating and sharing playlists and collections
photo:uploadUploading photos

A key cannot hold a scope its account lacks (403, listed in details.scopes). Library access, age limits, labels and viewing hours apply to every request as they do to the person. Anything the caller may not see answers 404, as if it did not exist.

Pagination and filters

Lists page with a cursor, not an offset, so a scan adding items while you page cannot shift the results:

curl -H "Authorization: Bearer co_…" \
  "https://media.example.com/api/v1/libraries/$LIBRARY_ID/items?limit=50&sort=-added_at&filter=year:gte:2000;genre:in:horror,thriller;unwatched:eq:true"
{
  "items": [{ "id": "…", "type": "movie", "title": "The Thing", "year": 1982 }],
  "page": { "cursor": "eyJzIjoiVGhlIFRoaW5nIiwiaSI6ImFiYyJ9", "hasMore": true, "total": 1284 }
}

Each item here is trimmed to a few fields. Send page.cursor back as cursor until hasMore is false. total comes with the first page. It is exact up to 10,000 items and an estimate beyond that, flagged by totalIsEstimate.

Filters. A filter is a set of field:op:value conditions joined by ;, all of which must hold, with , between list values.

  • Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, starts, between, exists.
  • Fields include:
    • type, title, year, added_at, rating, age_rating and person;
    • tags such as genre and label;
    • the caller's own progress, watched, unwatched, play_count and last_played;
    • custom fields, as custom.<key>.
  • Dates may be relative, such as now-30d.
  • Limits: a filter has at most 20 conditions, and a field not on the list is a 400, never a guess.

Sorting. sort takes up to three fields, with - for descending, or random for a shuffle that holds all day.

Smart collections and playlists store the same rules as JSON, nesting all and any groups up to three levels deep.

?include= adds related data to items, such as credits, artwork, progress or children; each endpoint lists what it accepts.

Errors

Every error has the same shape and a stable code to branch on:

{
  "error": {
    "code": "STREAM_LIMIT_REACHED",
    "message": "This account can watch 2 streams at a time. Stop one and try again.",
    "details": { "cap": 2, "active": 2 },
    "requestId": "01J8XQ4TQ0N0M6W8F2B8V7Y3KZ"
  }
}

The message is for people and may change; the code will not. requestId matches the server's log lines for the request.

StatusCommon codes
400VALIDATION_FAILED, with details.issues
401UNAUTHENTICATED, AUTH_TOKEN_EXPIRED, AUTH_TOKEN_INVALID, AUTH_2FA_REQUIRED
403FORBIDDEN, with details.permission; PERMISSION_TIME_RESTRICTED, with details.resumesAt
404, 409, 413NOT_FOUND, CONFLICT, PAYLOAD_TOO_LARGE
415MEDIA_NOT_PLAYABLE, MEDIA_UNSUPPORTED_CODEC, MEDIA_TRANSCODE_DISABLED
423STORAGE_UNAVAILABLE
429RATE_LIMITED, STREAM_LIMIT_REACHED
502PROVIDER_UNAVAILABLE, PLUGIN_ERROR
503SERVICE_STARTING

Anything unexpected is 500 INTERNAL with a generic message, and the detail stays in the log.

Rate limits

Each API key has its own limit per minute: 600 unless it was made with another, up to 10,000. Every answer to a key's request carries three headers:

  • X-RateLimit-Limit: the key's limit per minute.
  • X-RateLimit-Remaining: requests left this minute.
  • X-RateLimit-Reset: seconds until the minute starts again.

Over the limit, the answer is 429 RATE_LIMITED with details.retryAfterSec. Sign-in has its own limit: ten attempts a minute per address and per username.

Live events

GET /events is a server-sent event stream of what happens on the server:

curl -N -H "Authorization: Bearer co_…" \
  "https://media.example.com/api/v1/events?types=library.scan.completed,item.added"

Each event arrives with its id and name, and an envelope as its data:

{
  "id": "01J8XQ4TQ0N0M6W8F2B8V7Y3KZ",
  "event": "playback.started",
  "at": "2026-09-28T19:04:11.512Z",
  "payload": { "sessionId": "…", "userId": "…", "itemId": "…", "mediaFileId": "…", "method": "direct_play", "isRemote": false, "reasons": [] }
}

The events are:

  • library.scan.started, library.scan.progress, library.scan.completed and library.scan.failed;
  • item.added, item.updated and item.deleted;
  • playback.started, playback.paused, playback.resumed, playback.progress and playback.stopped;
  • download.ready;
  • user.created, invite.accepted, library.access.granted and playlist.shared;
  • notification.created and bug.reported;
  • system.storage.unavailable, system.storage.available and settings.changed;
  • watch.updated, watch.chat and watch.ended, for Watch Together rooms;
  • cast.command and cast.device.updated, between devices;
  • webhook.test.

types takes exact names, comma-separated.

A comment line every 20 seconds keeps proxies from closing the stream. After a drop, reconnect with the last id you saw in Last-Event-ID (or ?lastEventId=), and the server replays what you missed from its last thousand events.

The stream is scoped to the caller:

  • Events about a library reach only those who can see it.
  • Events about a person reach that person and those who manage sessions.
  • Without library:manage, an item event carries ids only.
  • No event carries a file path.

Webhooks

Webhooks push the same events to a URL. An admin creates one under Admin → Webhooks (see Backup, restore and upkeep) or with POST /webhooks. A webhook subscribes to exact event names, families such as playback.*, or *; casting's cast.* events are never sent. A webhook whose format is discord or slack posts a sentence into a chat room instead (see Backup, restore and upkeep); otherwise each delivery is a POST of the event envelope as JSON, with any headers you configured and these:

HeaderValue
X-Collectoh-EventThe event's name
X-Collectoh-DeliveryA unique id for the delivery
X-Collectoh-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook's secret

The secret is generated when the webhook is made and shown once, unless you supply your own of 16 characters or more; GET /webhooks/{id}?secret=true shows it again. Check the signature over the raw bytes, before parsing:

import { createHmac, timingSafeEqual } from 'node:crypto';

export function isFromCollectoh(rawBody: string, signature: string | undefined, secret: string): boolean {
  if (signature === undefined) return false;
  const expected = Buffer.from(`sha256=${createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`);
  const actual = Buffer.from(signature);
  return expected.length === actual.length && timingSafeEqual(expected, actual);
}

Answer with any 2xx within ten seconds. Anything else is retried after 10 seconds, 1 minute, 10 minutes, 1 hour and 6 hours, and a webhook switches itself off after 20 deliveries in a row have failed. POST /webhooks/{id}/test sends a webhook.test delivery and reports the status, body and signature.

The OpenAPI document and the client

Your server publishes this API's description at /api/v1/openapi.json (OpenAPI 3.1) and a readable version at /api/v1/docs. Neither needs a sign-in, and both work offline. Each operation has an operationId, its schemas, its errors and its permission.

@collectoh/client is the typed TypeScript client the Collectoh apps use, generated from that document. It has no dependencies and runs in browsers, Node 18 or later, and React Native:

npm install @collectoh/client
import { CollectohError, createClient } from '@collectoh/client';

const client = createClient({
  baseUrl: 'https://media.example.com',
  clientId: 'my-script-7f3a9c',
  clientName: 'My script',
  auth: { mode: 'api-key', key: process.env.COLLECTOH_KEY ?? '' },
});

try {
  for (const library of await client.getLibraries()) {
    const page = await client.getLibrariesByIdItems({
      params: { id: library.id },
      query: { limit: 50, filter: 'unwatched:eq:true', sort: '-added_at' },
    });
    console.log(library.name, page.items.map((item) => item.title));
  }
} catch (error) {
  if (error instanceof CollectohError) console.error(error.code, error.message, error.requestId);
  else throw error;
}

Methods are named by operationId. A failure is a CollectohError with its code, status, requestId and details. With a session, a 401 triggers one token refresh and one retry, never a loop; a key is never refreshed. onRateLimit in the options reports a key's remaining requests after each answer.

Versioning

/api/v1 changes only by adding: new endpoints, new optional parameters and new fields. A breaking change arrives as /api/v2. v1 is then kept for two minor releases, with Deprecation and Sunset headers on its answers in the meantime.

Endpoints by area