Overview
StreamTrack is the watch-tracking service behind StreamCube: history, watchlists, ratings, custom lists and reviews. This API is what StreamCube itself talks to, so anything the app can do, your integration can do too — scrobble from a media player, mirror another service, build a dashboard, or export everything and leave.
Base URL: https://streamtrack.online/api/v1
Every response is JSON wrapped in an envelope. Lists add pagination metadata:
{ "data": … }
{ "data": [ … ], "meta": { "limit": 50, "offset": 0, "count": 1284 } }
meta.count is the total number of matching rows on /history and /ratings — that is what makes paging them possible. Everywhere else it is the size of the page you just received, so page on until a response comes back shorter than limit.
Errors return the matching HTTP status and { "error": "…" } with a human-readable message.
AUTH Authentication
Create a personal token in StreamTrack → Settings → App access. It is shown once. Send it as a bearer token:
curl -H "Authorization: Bearer sct_your_token_here" \
https://streamtrack.online/api/v1/auth/me
Tokens carry scopes. A read-only token is rejected on any write with 403, so give an integration the least it needs. Check what you are holding:
GET /auth/me
→ { "data": { "user_id": 12, "profile_id": 3, "username": "regon",
"token": { "name": "Kodi", "prefix": "sct_ab12", "scopes": ["read"] } } }
A token is bound to one profile. Watch history, watchlists and ratings are per profile; reviews and follows belong to the account. If someone keeps separate profiles for themselves and their kids, use one token per profile.
Two read-only endpoints also accept ?token= in the URL, because calendar apps cannot send headers: /calendar.ics and /lists/{id}/feed.json. Nothing else does — a token in a URL ends up in browser history and server logs.
AUTH Signing in a device
Typing a forty-character token on a remote control is not a plan. A device asks for a pair of codes instead: a long one it keeps, and a short one it puts on the screen for somebody to confirm in a browser. Neither call needs a token — that is the whole point.
POST /device/code # { "client_name": "Kodi" }
→ { "data": { "device_code": "n4Utgw…", "user_code": "FWZT-DJ2C",
"verification_uri": "https://streamtrack.online/settings",
"expires_in": 600, "interval": 5 } }
Show user_code and verification_uri on screen, then ask every interval seconds whether somebody has confirmed it:
POST /device/token # { "device_code": "n4Utgw…" }
→ { "data": { "status": "pending" } }
→ { "data": { "status": "approved", "access_token": "sct_…" } }
Statuses are pending, approved, denied and expired. They all come back with HTTP 200 on purpose: a device polls in a loop, and telling "not yet" apart from a real fault by status code is a reliable way to write a broken client.
The token is handed out exactly once. Ask a second time and you get expired, whether the first answer arrived or not — so store it before you do anything else. From then on the device holds an ordinary personal token, revocable in Settings → App access like any other.
The confirming side lives in the web interface, so a normal integration never calls it. For completeness: GET /device/approve?code=… shows what is being approved, POST approves it for the signed-in profile and DELETE turns it down. That first step exists because confirming a code copied off somebody else's screen is exactly how people get talked into handing over access.
Referring to a title
Write endpoints take the same shape as Trakt, so existing integrations port easily:
{ "movie": { "imdb": "tt0137523" } }
{ "movie": { "tmdb": 550 } }
{ "show": { "imdb": "tt0903747" }, "episode": { "season": 1, "number": 2 } }
{ "movie": { "sc": 4210 } }
Identifiers are interchangeable — sc, tmdb, imdb, tvdb, trakt or simkl, whichever you have. sc is StreamTrack's own id and is unique across movies, shows, seasons and episodes.
A title you reference does not have to exist yet. Unknown ids are looked up in TMDB and created on the spot, so you never have to seed the catalogue first. That lookup costs a round trip, so prefer sc once you have it.
GET endpoints take the same identifiers as query parameters: ?type=episode&imdb=tt0903747&season=1&number=2.
When the player has no id at all
Some players know only what is written on the screen. Kodi add-ons are the usual case: many fill in a title, a show name and the episode numbers, and nothing else. For those, /scrobble accepts a title instead of an id:
{ "movie": { "title": "Dune", "year": 1984 } }
{ "show": { "title": "Severance" }, "episode": { "season": 1, "number": 3 } }
The title is matched against TMDB, and year is what separates a remake from the original. For a show only the show is matched by name — the episode comes from the numbers, because episode titles differ far too much between sources.
Send an id whenever you have one. Name matching is the weakest link in the chain: The Office exists twice and localised titles miss more often than they hit. It is a fallback, not a shortcut.
HTTP Watch history
Mark as watched
curl -X POST https://streamtrack.online/api/v1/watched \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"show":{"imdb":"tt0903747"},"episode":{"season":1,"number":2}}'
→ { "data": { "added": true, "synced_to_app": true, "media": { … } } }
synced_to_app tells you whether the play also reached StreamCube's shared history. It is false only for titles with no IMDB id, which cannot be keyed there. Do not ignore it — a false means the user will not see the tick in the app.
DELETE /watched with the same body removes it, from both StreamTrack and StreamCube.
Scrobbling
For players, report progress instead of a bare flag:
POST /scrobble
{ "movie": { "imdb": "tt0137523" }, "action": "start", "progress": 0 }
{ "movie": { "imdb": "tt0137523" }, "action": "pause", "progress": 34.5 }
{ "movie": { "imdb": "tt0137523" }, "action": "stop", "progress": 92.1 }
stop above 80 % counts as watched; below it the position is kept so the title shows up in Continue watching. The threshold is deliberate and matches StreamCube's players — do not round it yourself.
Send seconds as well when you know them:
{ "movie": { "imdb": "tt0137523" }, "action": "pause",
"progress": 34.5, "position_seconds": 2870, "duration_seconds": 8340 }
Percentages alone have to be converted back through the runtime in the catalogue, which is wrong for anything recut or padded with adverts. With position_seconds the position is stored exactly as the player reports it.
Position is stored on every action, not just on stop. A player that dies mid-film never says goodbye, and the last progress report is then all anybody has. Anything under a minute is ignored — opening a film and closing it again is not watching it.
Reading history
GET /history?limit=100&offset=0&type=movie&from=2025-01-01&to=2025-12-31
GET /history?months=1 # months that actually contain something, with counts
type takes movie or show; for shows it matches episodes, because plays are recorded per episode, not per series.
HTTP State, watchlist, ratings
GET /state?type=movie&tmdb=550
POST /watchlist DELETE /watchlist
GET /ratings?sort=rated_at|rating|title&type=&limit=&offset=
POST /ratings DELETE /ratings # { …ref, "rating": 8 } scale 1–10
GET /ratings/summary?type= # distribution
POST /collection DELETE /collection
GET /continue?status=hold|dropped|all # in progress
GET /status?status=hold|dropped # what you paused or dropped
POST /status DELETE /status # { …ref, "status": "hold", "note": "…" }
GET /discover?type=trending|popular|new
GET /recommendations?type=&limit= # because you watched X
GET /browse?genres=878,28&year_from=2015&rating_min=7.5&hide_watched=1
GET /genres?type=movie|show
GET /providers?type=movie|show®ion=CZ # services you can filter by
GET /networks # TV networks, for a brand hub
GET /collections GET /collections/{tmdb} # film franchises
GET /calendar?from=2026-01-01&days=7&scope=all|following&type=movie|show
For a show, /state returns progress and the next unwatched episode rather than a plain watched flag — that is the useful answer for a series.
/recommendations answers with { "items": [ { "media": …, "score": 4.2, "seed": … } ], "pending": false }. The seed is the title that earned the recommendation, so you can print "because Blade Runner 2049" rather than an unexplained grid. Results are computed from your own ratings and history against TMDB's recommendations, cached for a day, and recalculated in the background — a first call on a cold profile returns pending: true with an empty list. A profile with no history gets nothing at all rather than a repackaged popularity chart.
Watching status is the manual override for a show you paused or gave up on. Being "in progress" is otherwise derived — anything with a position on an unfinished episode qualifies — so a series you abandoned after three episodes would sit in /continue forever. Set hold or dropped and it drops out of the default listing; watching deletes the override rather than storing it, because that is the default. The status belongs to the show even when you send an episode reference, and it never touches history: dropping a series deletes nothing. /state returns the current value as status.
/browse also takes providers (TMDB provider ids, any of them match), monetization (flatrate, free, ads, rent, buy), region (default CZ), networks (TV only), country and runtime_min. Provider availability is always per region — "Netflix" means Netflix in that country. It is a different question from whether your own addons can play a title; that one is answered by the availability watcher.
The calendar covers everything that airs, not just titles somebody in your instance happened to open: a background job asks TMDB which shows have episodes due and which films are being released, and seeds the catalog with them. Entries carry kind (episode or movie), media — the thing being released — and, for episodes, the show it belongs to. Films appear in the shared listing only above a popularity threshold, because the catalog also collects stubs from everything anyone ever searched for; a film in your own watchlist shows up regardless.
/browse is the filtered counterpart to /discover: genres (TMDB genre ids, comma-separated or repeated, all of them must match), year_from, year_to, rating_min, runtime_max, original_language, sort (popularity, rating, newest, oldest), hide_watched. Results are created in the catalog as stubs, so you can rate or list them straight away. meta.count is the size of the page — TMDB does not report a usable total for a filtered query, so page on until a response comes back short. Use /genres to get the ids and names; that list changes about once a year and is cached for a day.
/ratings/summary returns the score distribution for the profile: { "count": 213, "average": 7.4, "histogram": [2, 0, 1, …] }. The histogram is always ten numbers and index 0 is score 1, so a score nobody gave is a zero rather than a missing key.
Ratings written here are mirrored to Trakt and SIMKL when the profile has them connected. So is watched state. Anything you import from those services is not sent back, so you cannot create a loop.
HTTP Is it playable yet?
StreamTrack watches your addons for titles you want and says when a stream turns up. The unit of work is a title paired with an addon endpoint, not a title paired with a user — a public addon answers everyone the same, so one probe serves every profile that has it. Your integration never has to poll addons itself; it reads the result.
The inbox
GET /notifications?unread=1&limit=50&offset=0
→ { "data": { "items": [ { "id": 91,
"kind": "stream_available",
"media_id": 4210,
"payload": { "addon": "Torrentio",
"stream_count": 12,
"preferred": false },
"created_at": "2026-08-19T20:14:03Z",
"read_at": null,
"media": { … } } ],
"unread": 7 },
"meta": { "limit": 50, "offset": 0, "count": 1 } }
The unread count rides along with the list on purpose — a badge and the list under it are one render, so they should not be two round trips. Titles arrive hydrated, poster and all, so there is nothing left to look up.
Notifications belong to a profile, not an account. Only profiles that actually have the addon the stream was found in hear about it, which is why one household member can be told and another not.
Watching the title removes it. A play recorded anywhere — here, in StreamCube, imported from Trakt — filters the notification out. Nothing has to be dismissed by hand.
POST /notifications # { "ids": [91, 92] } → { "read": 2 }
An empty body, or an empty array, marks everything read.
meta.count here is the size of the page, not the size of the inbox — keep asking until a page comes back shorter than limit.
A single title
GET /media/{sc}/alert
→ { "data": { "media_id": 812, "episode_id": 8140, "episode_label": "S03E07",
"watching": true, "alert": false, "checked": true,
"available": [ { "addon": "…", … } ] } }
For a show the thing being watched is the episode that is next in line — that is what episode_id and episode_label report — while the bell itself sits on the show. available lists only addons this profile has; promising a stream the user has nowhere to play would be worse than saying nothing.
POST /media/{sc}/alert → { "alert": true }
DELETE /media/{sc}/alert → { "alert": false }
The bell is kept separately from the targets derived from your watchlist, so recomputing those never erases a choice somebody made by hand. Without a selected profile it answers 400.
POST /media/{sc}/check
→ { "data": { "state": { … }, "pending": false } }
Checks one title out of turn. The call waits up to 20 seconds; pending: true means the probe is still running and state is what was known before it, so ask again shortly. A server with availability checking switched off answers 503.
Ready to play
GET /ready?kind=movie|show # both kinds when omitted
Everything the watcher has found that this profile has not seen yet. A notification is an event you clear; this is a state that clears itself the moment the title is watched. StreamCube's Ready to watch catalogue runs the same query, so the app and your integration cannot drift apart.
HTTP Jellyfin and Emby
Both servers can report playback themselves, and neither can attach a token to that report. The address is therefore the credential: create one per profile, paste it into your server's webhook settings, and what you play there shows up here.
GET /webhooks → { "data": [ { "url": "https://…/api/webhook/<secret>", … } ] }
POST /webhooks # { "kind": "jellyfin|emby", "label": "Living room",
# "match_user": "honza" }
GET /webhooks/{id} # includes the last payload received
PATCH /webhooks/{id} # label, match_user
DELETE /webhooks/{id}
Set match_user when the server has more than one user. Jellyfin and Emby report for everybody, so without it the whole household lands in one profile. It is matched against the user name in the event, ignoring case.
The address itself sits outside /api/v1, because it is a one-way pipe rather than part of the API:
POST https://streamcube.online/api/webhook/<secret>
In Jellyfin, add a Generic destination with Send All Properties switched on and the playback notifications ticked. In Emby, add the address under Webhooks and tick the playback events. Both shapes are understood, and so are Handlebars templates that render every value as a string.
What happens to an event
Films are matched on the id in the payload. Episodes are matched by series name plus season and episode numbers — not because that is a good idea, but because both servers send the id of the episode, and the catalogue is keyed on the show. Anything that is not a film or an episode is dropped.
Start, pause and stop are always processed. Progress reports are throttled to one every five minutes: Jellyfin can send them every few seconds, and each one that gets through costs a write and, for a show, a lookup.
Almost everything answers 200, including "this event was not interesting" and "this belongs to another user". Media servers switch a webhook off after a few failures and never mention it again, so a status code is reserved for a genuinely broken request. What actually happened is in the response:
→ { "data": { "accepted": true, "note": "" } }
→ { "data": { "accepted": false, "note": "Událost patří uživateli petra" } }
The last event received is kept in full and shown in Settings. Emby's payload shape is not documented anywhere public, so when something fails to match, that stored copy is the only way to find out why.
HTTP Lists
GET /lists → { "own": [ … ], "followed": [ … ] }
POST /lists # { "name": "…", "description": "…", "privacy": "private|public" }
GET /lists/{id} # also carries "mine" and "following"
PATCH /lists/{id} # name, description, privacy, auto_rule, clear_rule, show_in_app
DELETE /lists/{id}
POST /lists/{id}/items # …ref
DELETE /lists/{id}/items # …ref or { "media_ids": [1,2,3] }
GET /lists/{id}/feed.json # machine-readable feed, accepts ?token=
GET /lists/discover?q= # other people's public lists
Sharing and publishing are separate. Every list gets a share_token and can be sent to someone as /sctracking/s/{token}, whatever its privacy. privacy only decides whether the list is offered to others under Discover.
Automatic lists
A list can be a rule instead of a hand-picked selection:
PATCH /lists/{id}
{ "auto_rule": { "kind": "movie", "genres": ["Science Fiction"],
"year_from": 2015, "year_to": 2024, "rating_min": 8,
"watched": "unwatched", "in_watchlist": false,
"tmdb_query": "Star Wars", "limit": 100 } }
Every field is optional. kind is movie or show, empty means both. genres takes the English names the catalogue returns. year_from and year_to include their bounds. rating_min is your own rating, not TMDB's. watched is watched or unwatched, and in_watchlist narrows the rule to your watchlist.
tmdb_query is the odd one out: it searches TMDB rather than the catalogue, so it can pull in titles you have never touched and creates them as it goes.
All conditions apply together. A rule with no condition is rejected — it would return the whole catalogue. Send {"clear_rule": true} to make the list manual again.
The contents are stored, not computed on read. Setting or changing a rule fills the list straight away; after that it is recomputed once a day in the background. That is what gives an automatic list a real item count and a share link that does not open empty — the trade is that what you read can be up to a day old. Sending the same rule again forces a refresh.
Taking over someone else's list
POST /lists/{id}/copy # { "name": "…" }, name optional
GET|POST|DELETE /lists/{id}/follow
GET|POST|DELETE /lists/shared/{token}/like → { "liked": true, "like_count": 12 }
GET|POST|DELETE /lists/shared/{token}/follow
Copy or follow — they are different intentions. A copy is cut loose: it becomes yours to reorder and extend, and the next refresh of the source will not overwrite it. Following leaves the list where it is and keeps it live, which is usually the right choice for a curated one — you want to see what gets added to Star Wars, not to freeze it. Followed lists come back under followed in GET /lists.
Liking needs a signed-in user, which is why it lives on /lists/shared/{token}/like and not on the public share page. Following by token exists so that a share link is not single-use: pin the list once and you will find it again without it.
HTTP Reviews and people
GET /media/{sc} # detail
GET /media/{sc}/seasons?season=2
GET /media/{sc}/credits
GET /media/{sc}/extra # trailer, facts, similar titles
GET /media/{sc}/reviews
POST /media/{sc}/reviews # { "body": "…", "rating": 8, "has_spoilers": false }
DELETE /media/{sc}/reviews
GET /search/people?q= # actors and crew by name
GET /people/{sc}?filter=seen|unseen&sort=rating|popularity|oldest
GET /people/{sc}/credits # filmography alone
POST /people/{sc}/follow DELETE /people/{sc}/follow
GET /people/following # who this profile follows
GET /profile PATCH /profile # your public profile
GET /users/{id} # someone else's, only if public
POST /users/{id}/follow DELETE /users/{id}/follow
GET /users/{id}/together # titles both of you want to watch
GET /users/{id}/match # taste match from shared ratings
GET /users/search?q= # find people to follow
GET /feed?before=2026-08-20T12:00:00Z # what the people you follow watched
GET /review?period=2026 # your year, plus its share token
POST /review/share # { "period": "2026" } → { "token": … }
DELETE /review/share # { "token": … }
GET /review/shared/{token} # public, no auth
The feed only ever contains people whose profile is public — making a profile private hides its history from followers immediately, without breaking the follow. Episodes of one show watched on the same day collapse into a single entry carrying plays, so one binge does not bury everything else. Page it with before (the watched_at of the last row you got) rather than an offset: the feed grows while you read it.
/users/search matches usernames among public profiles only, and the filter is part of the query rather than a check over the result — otherwise the difference in a response would confirm that a private account exists. Each hit carries followed_by_me, so a client can render the follow button without a second call. You cannot follow a private profile even if you know its id: that returns 404, the same as an account that does not exist.
/users/{id}/match compares ratings you have both given and returns { "percent": 90, "common": 6, "meaningful": true }. Below five shared ratings meaningful is false and the number should not be shown — it would be noise dressed up as a statistic.
/review/share hands out a link to one period's summary. Asking twice returns the same token instead of minting another, and DELETE revokes it — the row stays behind so a revoked token is never reissued to somebody else. The public endpoint returns the summary and the username, never history or an account id; a share link is a key, and keys travel further than intended.
Reviews belong to the account, not the profile, and one person has one review per title — posting again edits it. A rating sent with a review is also stored as that user's rating, so a title never shows two different numbers from the same person.
Following a person puts a person_release notification in the inbox when something they worked on comes out — same inbox as stream_available, with payload.person_name and payload.role instead of an addon. Following is per profile, and it only reports titles released after you started following, so turning it on never floods you with somebody's back catalogue. Talk-show appearances (credited as "Self") are not reported.
Searching people is its own endpoint rather than a type=person on /search, so the shape of a response never depends on a query parameter. A person found this way is created in the catalog immediately, but the filmography is fetched in the background: the first request can come back with "pending": true and an empty list, and filling in takes a few seconds. /people/{sc} also returns progress — how many of that person's titles the profile has seen — and every credit carries seen and your own my_rating.
HTTP Getting the data out
GET /export/watched.csv
GET /export/watchlist.csv
GET /export/ratings.csv
GET /calendar.ics # subscribe from a phone, accepts ?token=
GET /review?period=2025-11 # summary for a month or a year
CSV columns are the intersection of what SIMKL, Letterboxd and IMDb can read, so the file is useful outside StreamTrack. For an episode the identity written is the show, refined by season and episode columns — that is what all three expect.
Worked example: a scrobbler
A minimal integration for a media player. It reports progress and marks the title watched when the user finishes.
import requests
API = "https://streamtrack.online/api/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {TOKEN}"
def ref(imdb, season=None, episode=None):
"""Trakt-style reference. Season and episode make it an episode."""
if season is None:
return {"movie": {"imdb": imdb}}
return {"show": {"imdb": imdb}, "episode": {"season": season, "number": episode}}
def scrobble(action, imdb, percent, season=None, episode=None):
body = ref(imdb, season, episode) | {"action": action, "progress": percent}
r = S.post(f"{API}/scrobble", json=body, timeout=10)
r.raise_for_status()
return r.json()["data"]
# Player events
scrobble("start", "tt0903747", 0.0, season=1, episode=2)
scrobble("pause", "tt0903747", 41.7, season=1, episode=2)
res = scrobble("stop", "tt0903747", 94.2, season=1, episode=2)
if res["watched"]:
print("Marked as watched; it will show up in StreamCube too.")
Reading a year back
r = S.get(f"{API}/review", params={"period": "2025"}).json()["data"]
print(r["human"], "—", round(r["summary"]["seconds"] / 3600), "hours")
for show in r["shows"][:5]:
print(f" {show['name']}: {show['count']}x")
Paging through history
offset, seen = 0, 0
while True:
page = S.get(f"{API}/history", params={"limit": 200, "offset": offset}).json()
seen += len(page["data"])
if seen >= page["meta"]["count"]: # on /history, count is every matching row
break
offset += 200
HTTP Everything else
GET /search?q=matrix&type=movie|show # searches TMDB and adds hits to the catalogue
GET /lookup?imdb=tt0137523 # resolve any id to an sc id
GET /stats # all-time totals, top genres, actors, heatmap
GET /people/{sc} # a person and what they appear in
GET /tokens POST /tokens # manage your own tokens
DELETE /tokens/{id}
POST /lists/{id}/fill # { "query": "Star Wars", "preview": true }
GET /lists/import?service=trakt|simkl # lists available to take over
POST /lists/import?service=trakt # { "list_ids": [42] } / { "list_keys": ["plantowatch"] }
POST /import # pull watched history from a connected service
GET /shared/{token} # a shared list, no authentication
/search creates catalogue entries for whatever it returns, so the results already carry an sc id you can write against immediately.
POST /lists/{id}/fill with "preview": true returns what would be added without adding it. Worth doing — a search occasionally drags in something unrelated.
/shared/{token} is the only endpoint that needs no token of your own: the share link is the credential.
Things worth knowing
Writes go through one path. Whatever you write here also lands in StreamCube's history and, when connected, in Trakt and SIMKL. There is no separate "StreamTrack-only" state to keep in sync.
Deletes are remembered. Removing something from history records a dismissal, so a later import will not resurrect it. If your integration re-imports on a schedule, this is what stops it fighting the user.
Duplicate plays are collapsed. The same title reported twice within five minutes counts once, so a retry after a network error is safe. Five minutes is the whole window, though — an offline queue flushed hours later writes a second play, so keep track of what you have already sent.
Rate limits. None enforced today. Calls that resolve unknown titles hit TMDB, so keep bulk imports paced and reuse sc ids you already have.