Stremshare External API (/api/v1)
On this page
- Contents
- Quick start
- Authentication
- Scopes
- Conventions
- Endpoints
- Filter config reference
- Recipes
- Troubleshooting
- 401 NO_API_KEY
- 401 INVALID_API_KEY
- 403 BETA_ONLY
- 403 AI_BETA_ONLY
- 403 INSUFFICIENT_SCOPE
- 403 NOT_MEMBER / NOT_CONTRIBUTOR
- 429 RATE_LIMITED
- Imports silently return 0 items
- Smart list never auto-syncs
- Smart list results shrink when adding imdbRating
- Template edits return 409 TEMPLATE_IS_PACK_DERIVED
- How do I rotate a key?
A stable, versioned HTTP API for managing groups, content, smart lists, templates, addon settings, and external imports from outside the web app. Built so tools like Claude Code (or any AI assistant that can make HTTP requests) can drive list building end-to-end without going through the React UI or simulating a session.
This document is the single source of truth for the /api/v1 surface.
Everything under /api/v1 is authenticated with an API key; the web
app's own /api/* routes (which use cookie + JWT) are unchanged.
Live copies of this document:
https://stremshare.com/docs/api: rendered HTMLhttps://stremshare.com/docs/api.md: raw markdown (ideal for pasting into an AI assistant or fetching programmatically)
Contents#
- Quick start
- Authentication
- Scopes
- Conventions
- Endpoints
- Filter config reference
- Recipes
- Troubleshooting
Quick start#
Log into the web app.
Go to Account → API Keys → New API Key.
Give it a name and select scopes (use "Select all" for a Claude-style key).
Copy the raw key. It's shown exactly once.
Verify:
curl -H "Authorization: Bearer ss_live_..." \ https://stremshare.com/api/v1/meYou should see a JSON response with your user ID and the key's scopes.
Authentication#
API key format#
ss_live_<32 bytes base64url>
ss_live_is a fixed prefix. It makes keys greppable in logs and picks up in GitHub secret scanners.- The full key is shown once at creation time. Only a SHA-256 hash is stored, so losing the plaintext means rotating the key.
- The first 12 characters (
ss_live_xxxx) are the "key prefix" and are displayed in the UI so you can identify keys later.
Sending the key#
Every request to /api/v1/* must include:
Authorization: Bearer ss_live_...
Missing, malformed, expired, or revoked keys get a 401. A key whose
scopes don't match what the endpoint requires gets a 403.
Beta gate#
The entire /api/v1 surface is currently in closed beta. Every
route additionally requires the key owner's account to have beta
features enabled. If your account isn't flagged, every call returns:
403 { "error": { "code": "BETA_ONLY", "message": "This feature is currently in closed beta. Contact the owner for access." } }
Contact the site owner to get your account flagged.
Key acts as its owner#
An API key operates as the user who created it. Every request is subject to the same group-role checks (owner / contributor / watcher) as a browser session for that user. There is no separate "API user": if you create a key on account A, it can only see account A's groups.
Managing keys#
In the UI (/account → API Keys):
- Create, list, revoke
- Scopes can be edited on existing keys
- Revocation is instant (no cache to bust)
Scopes#
Scopes are checked per-endpoint. A key can have any subset. The *
wildcard scope grants everything.
| Scope | Grants |
|---|---|
groups:read |
List groups, read group details |
groups:write |
Create groups, update metadata, change visibility |
groups:delete |
Delete groups |
content:read |
List content in a group |
content:write |
Add content, remove content, reorder |
smart_lists:read |
Read smart lists and their configs |
smart_lists:write |
Create, update, delete, manually sync smart lists |
addon_settings:read |
Read the user's Stremio addon settings |
addon_settings:write |
Update addon settings (enabled groups, order, etc.) |
discover:read |
List-builder discovery (TMDB genres, preview, etc.) |
import:write |
Import lists from Trakt / Letterboxd / MDBList |
templates:read |
List/read addon templates, rules, activation log |
templates:write |
Create, update, activate, duplicate templates; manage auto-switch rules |
templates:delete |
Delete templates |
starter_packs:subscribe |
Subscribe to a starter pack |
* |
All of the above |
Group-role checks layer on top of scopes. Example: content:write
plus group-member status lets you add content, but deleting a group
still requires groups:delete AND owner role.
Conventions#
Base URL#
Production: https://stremshare.com/api/v1
Local development: http://localhost:3000/api/v1
Success envelope#
{
"data": { ... },
"meta": { ... } // optional: counts, pagination, echoed params
}
For list endpoints, data is an array.
Error envelope#
{
"error": {
"code": "MACHINE_READABLE_CODE",
"message": "Human-readable description",
"details": { ... } // optional, e.g. required vs. granted scopes
}
}
Common codes:
| Code | HTTP | Meaning |
|---|---|---|
NO_API_KEY |
401 | Authorization header missing or malformed |
INVALID_API_KEY |
401 | Key not found, revoked, or expired |
INSUFFICIENT_SCOPE |
403 | Key lacks one or more required scopes |
BETA_ONLY |
403 | Key owner's account isn't beta-enabled |
AI_BETA_ONLY |
403 | An AI feature (the AI Curator) is in a closed beta the key owner isn't in; see AI Curator |
NOT_MEMBER |
403 | User is not a member of the group |
NOT_CONTRIBUTOR |
403 | Contributor-or-owner role required |
FORBIDDEN |
403 | Action not allowed (e.g. deleting another user's content) |
NOT_FOUND |
404 | Resource does not exist |
INVALID_INPUT |
400 | Request body or query params failed validation |
PLAN_REQUIRED |
402 | The account's plan doesn't include this; see Plans |
RATE_LIMITED |
429 | Too many requests; see Rate limits |
UNAVAILABLE |
503 | A dependent service (e.g. sync) is offline |
INTERNAL |
500 | Unexpected server error |
Resource-specific codes (TEMPLATE_IS_ACTIVE,
STARTER_PACK_HAS_NO_GROUPS, …) are documented inline with their
endpoints.
Plans and 402 PLAN_REQUIRED#
Every account is on a plan: free (the default; tier value free) or
Supporter (the API's tier value is plus). Supporter isn't available to
buy yet; the Stremshare team grants it. A handful of calls do more on
Supporter, and a free account asking for one of those gets a 402 (never a
403) with the feature it needs in details:
{
"error": {
"code": "PLAN_REQUIRED",
"message": "This feature requires an upgraded plan.",
"details": { "feature": "groups_unlimited", "limit": 50, "current": 50 }
}
}
| Feature | Where it bites |
|---|---|
groups_unlimited |
POST /groups: a free account may own at most the free limit (a server setting, default 50) of groups (the AI curator's own groups don't count). details carries limit and current, so read the number from there. Accounts already over the limit keep their groups; they just can't create another. Not capped at all: Supporter accounts, site admins, and accounts that already owned the free limit or more when the limit was introduced (kept unlimited permanently). GET /me → data.plan.groupsUnlimited says which applies to you |
curator_daily |
PATCH /curator with cadence: "daily". Free accounts can pick weekly or monthly |
curator_profiles |
PATCH /curator?profile=<id> turning a profile's curator on, and POST /curator/refresh?profile=<id>. A profile's own curator is a Supporter feature; the account's curator stays free |
curator_dayparts |
PATCH /curator with dayparts: true (different rows for mornings and evenings). Turning it on needs Supporter; resending the stored true or turning it off is never refused |
Nothing is written when a call answers 402, not even the other fields in
the same body. GET /me reports the key owner's plan (data.plan), so a
client can check up front instead of discovering the limit by hitting it.
A 402 is not retryable; it needs the account's plan to change.
Supporter also raises the AI search daily cap: the app's natural-language
search (Add Content → Ask) and the Stremio addon's AI search row. That is a
quota, not a 402, and it is not an /api/v1 route: free accounts get
60 AI searches a day, Supporter accounts 300 (server settings
AI_SEARCH_PER_DAY / AI_SEARCH_PER_DAY_PLUS; only searches that actually
reach the model count, and repeats of an already-answered phrase are free).
Over the cap the app answers 429 AI_QUOTA_EXCEEDED with the account's own
cap (perDay) and the Supporter one (plusPerDay), and the addon row comes back
empty. A plan that lapses drops to the free cap on the next search.
Rate limits#
Limits are applied per API key (not per IP), on a fixed one-minute window:
| Limit | Default | Applies to |
|---|---|---|
| General | 120 req/min | Every /api/v1 route |
| Expensive | 10 req/min | POST /discover/preview, POST /discover/execute, POST /groups/:id/import, POST /smart-lists/:id/sync |
The expensive limit stacks on top of the general one: a request to an expensive endpoint counts against both windows. These four endpoints fan out to external services (TMDB, OMDB, list scrapers), hence the tighter budget.
Exceeding a limit returns 429 with the standard error envelope and a
Retry-After header (seconds):
{
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded, please slow down",
"details": { "retry_after": 42 }
}
}
Every response also carries X-RateLimit-Limit, X-RateLimit-Remaining
and X-RateLimit-Reset (ISO timestamp) headers so clients can pace
themselves before hitting the wall.
Server operators can tune the defaults via environment variables:
V1_RATE_LIMIT_MAX / V1_RATE_LIMIT_WINDOW_MS (general) and
V1_EXPENSIVE_RATE_LIMIT_MAX / V1_EXPENSIVE_RATE_LIMIT_WINDOW_MS
(expensive).
IDs#
- Group IDs: 8-char hex (
d1728a89) - Smart list IDs: UUID (
5daff029-8ff6-42ef-ac2d-eb96b7acfe31) - Content IDs: integer
- Template IDs and template rule IDs: integer
- Starter pack IDs: slug (
horror-essentials)
Endpoints#
Me / ping#
GET /api/v1/me
No scopes required beyond a valid key. Useful for verifying auth.
plan.tier is free or plus (Supporter); plan.features lists what the plan unlocks
(see Plans); plan.expiresAt is null for a
plan with no end date. plan.groupsUnlimited is true when the owned-group
limit doesn't apply to you: Supporter, or an account exempt from it (site admins,
and accounts that already owned the free limit when it was introduced). user.timezone is the IANA time zone your
auto-switch rules and the AI Curator's
mornings/evenings switch run in. It is null (read as UTC) until a browser signed
in to the website has reported one. ai says which AI features you can use right
now: search and curator are each available, beta (the feature is in a
closed beta you're not in, so its endpoints answer 403 AI_BETA_ONLY) or off
(not running on this server, so its endpoints answer 404); beta is true
when your account is in the AI beta.
curl -H "Authorization: Bearer $KEY" https://stremshare.com/api/v1/me
{
"data": {
"user": { "id": 2, "username": "moviefan", "email": "...", "timezone": "America/New_York" },
"plan": { "tier": "free", "expiresAt": null, "features": [], "groupsUnlimited": false },
"ai": { "search": "available", "curator": "available", "beta": true },
"api_key": { "id": 1, "name": "Claude Code", "prefix": "ss_live_xxxx", "scopes": [...] }
}
}
Groups#
GET /api/v1/groups
Scopes: groups:read
Returns: every group the user is a member of, with their role.
POST /api/v1/groups
Scopes: groups:write
Body: { "name": "...", "description": "...?", "is_public": false }
Returns: the created group (user auto-added as owner, auto-enabled in their addon).
Plan limit: a free account may own at most the free limit (a server
setting, default 50; the 402's details.limit is the live number) of
groups. Groups the app made for you don't count: the AI curator's shelves, your Up Next group and
your My Recommendations / Because You Watched groups. At the limit this answers
402 PLAN_REQUIRED with details: { feature: "groups_unlimited", limit, current }
and creates nothing. Supporter accounts have no limit, and neither do site admins or
accounts that already owned the free limit when it was introduced (grandfathered
permanently)
(GET /me → plan.groupsUnlimited).
curl -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"name":"80s Action","description":"Decade + genre filter"}' \
https://stremshare.com/api/v1/groups
GET /api/v1/groups/:id
Scopes: groups:read
Auth: member of the group, OR the group is public.
PATCH /api/v1/groups/:id
Scopes: groups:write
Role: owner or contributor.
Body: any subset of { name, description, genres, is_public }.
PUT /api/v1/groups/:id/visibility
Scopes: groups:write
Role: owner only.
Body: { "is_public": true | false }
DELETE /api/v1/groups/:id
Scopes: groups:delete
Role: owner only.
Cascades: deletes content, members, invites, and smart lists belonging to the group.
Content#
Content is always scoped to a group.
GET /api/v1/groups/:id/content
Scopes: content:read
Query: type=movie|series, limit, offset, genre, addedBy, search
POST /api/v1/groups/:id/content
Scopes: content:write
Role: any member.
Two shapes accepted:
{ "contentId": "tt1234567" }
or bulk (up to 200 items per request):
{
"items": [
{ "contentId": "tt0111161" },
{ "contentId": "tt0068646", "displayOrder": 5 },
"tt0071562"
]
}
Each item is enriched via OMDB (title, poster, year, etc.) and broadcast to other connected clients as a socket event.
Don't know the IMDB ID? Use
GET /discover/searchto resolve titles to IMDB IDs first.
Returns:
{
"data": {
"added": [{ "input": "tt0111161", "imdb_id": "tt0111161", "info": {...}, "content_id": 42 }],
"skipped": [{ "input": "tt0068646", "reason": "duplicate" }]
},
"meta": { "added_count": 1, "skipped_count": 1 }
}
DELETE /api/v1/groups/:id/content/:contentId
Scopes: content:write
Role: member. Permission enforced: you can only delete content you
added, unless you're the group owner.
PUT /api/v1/groups/:id/content/reorder
Scopes: content:write
Role: owner only.
Body: { "order": [{ "id": 42, "display_order": 10 }, ...] }
Addon settings#
Per-user, not group-scoped.
GET /api/v1/addon-settings
Scopes: addon_settings:read
PATCH /api/v1/addon-settings
Scopes: addon_settings:write
PATCH is merge-based: send only the fields you want to change.
Updatable fields:
| Field | Type | Notes |
|---|---|---|
enabled_groups |
string[] |
Group IDs visible in the Stremio addon. An empty array means every group you belong to is shown (the never-configured default), not none |
group_order |
string[] |
Display order |
allowed_stream_groups |
string[] |
Groups that may serve stream options |
random_order_groups |
string[] |
Groups with randomized content order |
group_catalog_settings |
object |
{ "<groupId>": { movies, series, collection, combined } } |
use_stream_video |
boolean |
Global system-video toggle |
stream_feedback_mode |
string | null |
What the "Add to List" stream does after adding: webpage (a confirmation page that jumps back into Stremio) or video. null falls back to use_stream_video; sending null leaves the stored choice as-is |
enable_scrobbling |
boolean |
Global scrobbling toggle |
poster_provider |
string | null |
Active rating-poster provider: rpdb, openposterdb, top_posters, betterposters, xrdb or tmdb_regional. Sending null clears it (back to the default OMDB/TMDB posters). The provider's API key is managed separately, under Connections |
poster_language |
string | null |
2-letter ISO 639-1 code for catalog row labels and tmdb_regional poster lookups. Sending null clears it |
poster_rated_episodes |
boolean | 0|1 |
Opt in to provider episode thumbnails on detail pages (needs a provider that renders them) |
poster_rated_artwork |
boolean | 0|1 |
Opt in to provider logos/backdrops on detail pages (needs a provider that renders them) |
hide_watched_global |
boolean |
Account-level default for hiding already-watched titles from catalogs. Movies hide on any watch; a series hides only once every released episode is watched |
hide_watched_groups |
object |
Per-group override map { "<groupId>": "hide" | "show" }. A key wins over hide_watched_global in either direction; omit a group to inherit it. Send {} to clear every override; unknown values are dropped |
If you find yourself flipping addon settings between whole configurations (e.g. "normal" vs "kids weekend"), use Templates instead. They snapshot and restore the whole settings object in one call.
Discovery (list builder)#
All require discover:read. Reference-data endpoints return the
upstream TMDB shape unchanged for ergonomic filter construction.
| Method | Path | Notes |
|---|---|---|
| GET | /discover/genres?mediaType=movie |
TMDB genre IDs + names |
| GET | /discover/providers?region=US&mediaType=movie |
Streaming providers |
| GET | /discover/languages |
All TMDB languages |
| GET | /discover/certifications?country=US |
Ratings (G, PG, R, etc.) |
| GET | /discover/people?q=nolan |
Search actors/directors |
| GET | /discover/keywords?q=cyberpunk |
Search keywords |
| GET | /discover/networks?q=hbo |
TV networks |
| GET | /discover/companies?q=a24 |
Production companies |
| GET | /discover/search?q=inception |
Title → IMDB ID lookup (see below) |
| POST | /discover/preview |
First-page preview (see below) |
| POST | /discover/execute |
Full multi-page discovery |
GET /discover/search
Search movies/series by name and get back IMDB IDs you can feed directly into content add.
Query: q (required), mediaType=movie|tv|both (default both),
year, limit
{
"data": [ { "imdb_id": "tt1375666", "title": "Inception", ... } ],
"meta": { "query": "inception", "mediaType": "both", "year": null, "count": 1 }
}
POST /discover/preview
Body:
{ "filter_config": { /* see filter config reference */ } }
Returns: { items[], totalResults, totalPages, page, excludedCount, noImdbCount }
The response meta.source is tmdb or anilist; anime-mode filter
configs are routed to AniList automatically (see
Anime mode).
POST /discover/execute
Body:
{
"filter_config": { ... },
"max_pages": 5, // capped at 20
"start_page": 1
}
Smart lists#
Smart lists are stored filter configs that auto-sync matched items into a group on a schedule. They're the backbone for "keep this collection fresh" use cases.
Group-scoped (composed with group-role middleware):
| Method | Path | Scopes | Role |
|---|---|---|---|
| GET | /groups/:id/smart-lists |
smart_lists:read |
member or public group |
| POST | /groups/:id/smart-lists |
smart_lists:write |
owner or contributor |
Top-level by UUID (authorization resolved from the list's group_id):
| Method | Path | Scopes | Role |
|---|---|---|---|
| GET | /smart-lists/:id |
smart_lists:read |
member of owning group |
| PATCH | /smart-lists/:id |
smart_lists:write |
owner or contributor |
| DELETE | /smart-lists/:id |
smart_lists:write |
owner or contributor |
| POST | /smart-lists/:id/sync |
smart_lists:write |
owner or contributor |
Create body
{
"name": "Dramas 2020+",
"description": "optional",
"filter_config": { /* see filter config reference */ },
"sync_frequency": "daily",
"sync_mode": "append",
"is_active": true,
"page_progression_enabled": false,
"source_type": "list_builder"
}
sync_frequency:manual(default),hourly,every_6_hours,daily,every_3_days,weekly.sync_mode:append(default: new matches are added, existing content stays) orreplace(each sync makes the group's list-derived content mirror the current results).source_type:list_builder(default),trakt,letterboxd,mdblist,flixpatrol,predb. External source types are normally set by the import endpoint, and creating one manually is uncommon.imdbis no longer accepted (see IMDB imports).page_progression_enabled: whentrue, each sync fetches the next page of results instead of re-fetching page 1, which is useful for gradually deepening a large catalog.
PATCH accepts the same fields (all optional; send only what changes).
Sync response
{
"data": { "added": 6, "alreadyInGroup": 0, "total": 6 },
"meta": { "id": "<uuid>", "name": "..." }
}
External imports#
One endpoint wraps all four external sources. Choose between a one-shot bulk import or a recurring auto-syncing smart list.
POST /api/v1/groups/:id/import
Scopes: import:write
Role: any member.
Body:
{
"source": "trakt" | "letterboxd" | "mdblist",
"url": "https://...",
"create_smart_list": false,
"sync_frequency": "daily", // only when create_smart_list=true
"smart_list_name": "optional" // only when create_smart_list=true
}
One-shot response (when create_smart_list is false or omitted):
{
"data": { "imported": 81, "skipped": 0, "failed": 0 },
"meta": { "source": "mdblist", "url": "...", "fetched": 81 }
}
Smart list response (when create_smart_list: true): a smart list
is created and the first sync runs immediately:
{
"data": {
"smart_list": { /* full smart list object */ },
"sync_result": { "added": 81, "alreadyInGroup": 0, "total": 81 }
}
}
URL formats by source:
| Source | Example |
|---|---|
| Trakt | https://trakt.tv/users/<user>/lists/<list-slug> |
| Letterboxd | https://letterboxd.com/<user>/list/<list-slug>/ |
| MDBList | https://mdblist.com/lists/<user>/<list-slug>/ |
IMDB imports are retired
IMDB now serves an interactive "Human Verification" challenge to all
automated traffic, so direct IMDB list imports are no longer supported.
source: "imdb" returns 410 Gone with code IMDB_IMPORT_RETIRED,
and source_type: "imdb" is rejected on smart-list create.
Two workarounds:
- Search MDBList for a mirror of the list and import that instead.
- Export the list from IMDB as CSV (the Export option on the list page) and upload it through the web UI's file-upload import.
Templates#
A template is a named, saved snapshot of your entire addon configuration: which groups are enabled, their order, stream/catalog settings, etc. Exactly one template is active at a time; activating one writes its config into your live addon settings (and pushes to Stremio Link if connected). Templates let you flip your whole addon between presets ("Default", "Halloween", "Kids") in a single call, and pair with auto-switch rules for calendar-driven switching.
Templates created by subscribing to a starter pack
are pack-derived (derived_from_collection_id set) and read-only;
duplicate one to get an editable copy.
Template object
{
"id": 12,
"user_id": 2,
"name": "Halloween",
"is_active": false,
"is_managed": false,
"is_curator": false,
"curator_profile_id": null,
"derived_from_collection_id": null,
"activation_rule": null,
"enabled_groups_count": 5,
"config": { /* detail endpoints only — see below */ },
"created_at": "...", "updated_at": "...", "last_activated_at": null
}
is_curator is true on a template the AI Curator builds
(it is also is_managed). Its group list is the curator's: every plan
rewrites which groups are on it and their order, so the API refuses to change
them (see PATCH below). Steer the curator instead. curator_profile_id is
the profile whose own curator owns it, or null for your account's curator;
a profile's curator template is switched in by that profile's curator and
can't be activated for your whole account.
config shape (same field meanings as addon settings):
{
"enabled_groups": ["d1728a89", "..."],
"group_order": ["d1728a89", "..."],
"allowed_stream_groups": [],
"stream_order": [],
"random_order_groups": [],
"group_catalog_settings": { "d1728a89": { "movies": true, "series": true } },
"hide_watched_groups": { "d1728a89": "hide" }
}
hide_watched_groups is a per-group map ({ "<groupId>": "hide" | "show" })
of the template's Hide Watched choices; activating the template applies it to
your live addon settings the same way the rest of config does. It's
currently read-only through the v1 API: POST /api/v1/templates
snapshots it from your current settings and GET returns it, but
PATCH /api/v1/templates/:id does not accept it (see below).
GET /api/v1/templates
Scopes: templates:read
All of the user's templates. List entries omit config.
POST /api/v1/templates
Scopes: templates:write
Body: { "name": "...", "config": { ... }? }. Name is required
(1–100 chars); unknown top-level keys are rejected. The name
Saved configuration (any case) is reserved for your auto-saved setup →
400 RESERVED_TEMPLATE_NAME.
- Omit
config→ the server snapshots your current addon settings into the template. This is how you "save current state". - Provide
config→enabled_groupsmust only contain groups you are a member of, else409 TEMPLATE_GROUPS_NOT_SUBSET(withdetails.missing_group_ids).group_orderis set to match.
Returns: 201 with the template (including config).
GET /api/v1/templates/:id
Scopes: templates:read
Template + config + a hydrated groups array
({ id, name, members_count, items_count }, order preserved).
PATCH /api/v1/templates/:id
Scopes: templates:write
Body: any of name, enabled_groups, allowed_stream_groups,
stream_order, random_order_groups, group_catalog_settings
(at least one required). Stream fields must be subsets of the effective
enabled_groups, else 400 STREAM_FIELDS_NOT_SUBSET. hide_watched_groups
is not an accepted field here. It can only be changed by activating a
template whose config carries the choices you want (or via the session-auth
web UI).
- Pack-derived templates are read-only →
409 TEMPLATE_IS_PACK_DERIVED. - AI Curator templates (
is_curator: true):enabled_groups(orgroup_order,addon_group_order,curator_group_name_overrides,group_name_overrides) →409 TEMPLATE_IS_CURATOR, with the offending keys indetails.fields; nothing is written. A changednameis refused the same way (nameappears indetails.fields), since the curator names its templates; resending the current name is fine. The stream fields still save. On these templates stream-field entries for a group the curator has since dropped are silently removed instead of400 STREAM_FIELDS_NOT_SUBSET. - Renaming the auto-saved "Saved configuration" template (or a profile's
remembered catalog) →
409 TEMPLATE_IS_MANAGED; renaming any other template to that name →400 RESERVED_TEMPLATE_NAME. - Editing the currently active template re-syncs your live addon
settings; if that push fails the response still succeeds but includes
a top-level
"sync_warning": "visibility_sync_failed".
DELETE /api/v1/templates/:id
Scopes: templates:delete
Refuses to delete: pack-derived templates
(409 TEMPLATE_IS_PACK_DERIVED), the active template
(409 TEMPLATE_IS_ACTIVE), or your last remaining template
(409 TEMPLATE_LAST_REMAINING).
POST /api/v1/templates/:id/activate
Scopes: templates:write
Body: none. Makes the template active: joins/leaves groups to match
it, writes its config into live addon settings, pushes to Stremio Link,
and may auto-save your previous state if it had diverged.
A profile's AI Curator template (curator_profile_id set) answers
409 TEMPLATE_IS_CURATOR unless it is already active (then the usual
already_active: true no-op), because that profile's curator switches it in.
Your account's own curator template activates normally.
Returns:
{
"data": {
"template_id": 12,
"activated": true,
"already_active": false,
"joined": ["<group-id>"],
"left": [],
"auto_saved": null,
"catalog_size": 7
}
}
POST /api/v1/templates/:id/duplicate
Scopes: templates:write
Body: none. Deep-copies the template as "<name> (Copy)"
((Copy 2), … on collision). The copy is never pack-derived, so this is
the escape hatch for editing a starter-pack template. AI Curator templates
can't be duplicated (409 TEMPLATE_IS_CURATOR): the curator retires their
groups over time, so a copy would go stale.
Template auto-switch rules#
Rules attach to a template and automatically activate it based on the
calendar/clock, evaluated in the user's timezone (GET /me →
user.timezone; UTC if unset, and the website reports your browser's zone when
you open the dashboard) by a scheduler that ticks roughly every 15 minutes.
When multiple enabled rules match, the highest priority wins.
Rule object
{
"id": 3,
"template_id": 12,
"rule_type": "holiday",
"config": { "holiday_id": "us_halloween", "days_before": 14, "days_after": 1 },
"priority": 100,
"enabled": true,
"created_at": "...", "updated_at": "..."
}
Rule types and their config
rule_type |
Config fields |
|---|---|
date_range |
start/end (YYYY-MM-DD, end ≥ start), optional time_start/time_end (HH:MM) |
annual_date_range |
month_day_start/month_day_end (MM-DD; may wrap the year boundary, e.g. 12-28→01-05), optional time_start/time_end |
day_of_week |
days (int[] 0–6, Sun=0), optional time_start+time_end (both or neither; window may cross midnight) |
holiday |
holiday_id, days_before (0–60), days_after (0–60) |
Valid holiday_id values (US fixed-date holidays): us_new_years_day,
us_valentines_day, us_st_patricks_day, us_april_fools,
us_cinco_de_mayo, us_independence_day, us_halloween,
us_veterans_day, us_christmas_eve, us_christmas, us_boxing_day,
us_new_years_eve.
Rule scope: account-wide, or one always-on profile
Every rule carries a profile_id. It is null by default and that is the
original behavior: the rule is account-wide, and when it matches, its
template is activated for your whole account.
Give it the id of a profile connected to Nuvio or Stremio and the rule is scoped to that profile instead: when it matches, the template becomes that profile's view source (its own addon URL starts serving the template's config) and nothing is activated. The account's active template, and every other profile, are untouched. Scoped rules compete only with each other, so a profile rule can never win the account's pick (or the reverse).
Two extra refusals apply only to scoped rules:
409 PROFILE_NOT_BOUND: the profile isn't connected to Nuvio or Stremio, so it has no addon URL of its own to point anywhere.400 TEMPLATE_IS_MANAGED: the target template is one Stremshare rewrites for you, so a profile following it would change without you asking.
And one applies to every rule you write: an AI Curator template
(is_curator: true, your account's or a profile's) can't be a rule's target
at all: POST /template-rules answers 409 TEMPLATE_IS_CURATOR, since the
curator decides when its templates go live. For such a rule written before
this check, a PATCH answers the same only when the rule would end up
enabled (switching it off, and editing it while off, always work),
DELETE always works, and the scheduler skips it. The curator's own rules
(created_by_curator: true) are unaffected.
Changing a profile's view by hand (following a template, clearing it, or
saving its catalog) pauses that profile's scoped rules for 2 hours, so
automation can't immediately shadow what you just did. That window is
per-profile: /template-rules/pause still pauses the account lane only, and
neither pause quiets the other.
When a scoped rule stops matching, nothing is reverted. The last attachment stands, the same way the account lane leaves the last activation standing.
Rules managed by the AI Curator
Every rule carries created_by_curator (boolean). It is true on the two
day_of_week rules the AI Curator writes when its
mornings and evenings option (dayparts) is on: one for its "Day"
template (every day, 05:00–17:00) and one for its "Night" template
(17:00–05:00, a window that wraps past midnight). They never compete
with your own rules: the scheduler only consults them when none of yours
matches, to decide which half of the curated homepage to show, so a rule of
yours always wins, at any priority (their own priority of 100 is
irrelevant), and the switch respects the same things the curator always
does: an active pause, a template you picked by hand, and a switched-in
profile's own curator. They are read-only: PATCH and DELETE on one
answer 409 RULE_MANAGED_BY_CURATOR. They disappear when you turn dayparts
off, turn the curator off, or lose Supporter.
Endpoints
| Method | Path | Scopes | Notes |
|---|---|---|---|
| GET | /template-rules?template_id=12 |
templates:read |
Rules for one template. Pass exactly one of template_id or profile_id (profile_id=7 lists that profile's scoped rules, enabled or not) |
| POST | /template-rules |
templates:write |
Body: { template_id, rule_type, config, priority?=100, enabled?=true, profile_id?=null } → 201 |
| GET | /template-rules/:id |
templates:read |
|
| PATCH | /template-rules/:id |
templates:write |
Any of rule_type, config, priority, enabled, profile_id (omit to keep the current scope; null widens it back to account-wide) |
| DELETE | /template-rules/:id |
templates:write |
Returns { "data": { "id": 3, "deleted": true } }. 409 RULE_MANAGED_BY_CURATOR on a curator-managed rule (as does PATCH) |
| GET | /template-rules/log |
templates:read |
Activation history. Query: limit (default 50), before (cursor). Entries: { id, template_id, template_name, source, rule_id, reason, profile_id, profile_name, triggered_at }; source ∈ manual, auto, subscribe, admin, profile_update. profile_id/profile_name are set when a scoped rule attached a template to that profile's view (profile_name reads null once the profile is deleted) |
| GET | /template-rules/next-switch |
templates:read |
Predicts the next account auto-switch: { template_id, template_name, at, rule_id, source } or null, as the scheduler will actually make it: nothing before a live pause ends, and with the AI Curator on, its fallback when your rules go quiet. source is rule, curator_daypart (the mornings/evenings half) or curator_fallback (the curated homepage; rule_id: null). meta: { held, paused_until } says why nothing may be due: held is "paused", "manual_choice" (a template you picked by hand is live, so the curator waits until something else replaces it) or null. Add ?profile_id=7 to ask the same of one profile's view. Looks ahead ~400 days; windows narrower than ~3h beyond the first day may be missed by its sampling |
| GET | /template-rules/pause-status |
templates:read |
`{ "paused_until": " |
| POST | /template-rules/pause |
templates:write |
Body: { "until": "<ISO datetime>" }; omit until → 24h from now; "until": null → indefinitely |
| POST | /template-rules/resume |
templates:write |
Clears the pause |
AI Curator#
The curator re-plans your Stremio homepage on a cadence you choose. Each run asks a language model for a set of rows, then builds them: private smart-list groups for the shelves it invents, memberships in public groups it picks, and a managed template named AI Curator that it activates.
It is opt-in and off by default, and it can be switched off entirely
at the deployment level. When the server has it off, every endpoint in
this section answers 404 with CURATOR_DISABLED. That is the server
saying it has no curator, not a problem with your request. Don't retry it.
While the curator is in a closed beta, an account that isn't in it gets
403 AI_BETA_ONLY ("This feature is in beta.") from every endpoint in this
section, including the reads, and nothing is read or changed. GET /me →
data.ai.curator tells you up front: available, beta (this 403) or off
(the 404). Don't retry it; access is granted by the site owner.
One thing still works outside the beta: switching a curator off. A curator
that was on before the account left the beta keeps running its schedule
switches, so PATCH /curator with a body that only sets enabled: false
and/or dayparts: false is carried out (also while the site owner has AI
switched off) and answers 200 { data: { enabled, dayparts, lane, pending? } }.
Any other body gets the 403. The 403 from GET /curator says whether there
is anything to switch off, in error.details:
{ curatorEnabled, daypartsOn, enabledLanes: [{ profileId, name }] } (the
first two describe the lane you asked for; enabledLanes lists every lane
still on, profileId null for the account).
Scopes reuse the template scopes: templates:read to read,
templates:write for everything else. What the curator writes is a
template, so a key trusted to author templates is already trusted here,
and one that isn't must not be able to hand that authoring to a model.
Per-profile curators
Every endpoint in this section takes an optional ?profile=<id> query
parameter. Without it you are talking to the account's curator; with a
Stremio profile id you are talking to that profile's own curator, with its
own on/off, cadence, instructions, plan, "Always keep" list, history and
refresh allowance, planned from that profile's own watch history and at that
profile's content ceiling. Two caps are account-wide, not per profile: at
most 8 profiles may have their curator on at once (409 CURATOR_LANE_LIMIT),
and manual refreshes are also counted summed over the account and every
profile (10 a day with Supporter, otherwise 4 a week: 429 CURATOR_ACCOUNT_REFRESH_LIMIT; see POST /curator/refresh).
Both are server defaults. lanes on the curator object lists the ids you
can pass. A profile that is not yours is 404 with PROFILE_NOT_FOUND; a
malformed id is 400.
What a profile's curator does with the homepage it builds depends on the
profile: a profile connected to Nuvio or Stremio (kind: "bound")
follows the template as its own add-on view, and its add-on is re-pushed; a
switchable profile (kind: "switchable") switches in to the template,
immediately when it is the profile switched in right now, otherwise the next
time it is. A profile's own auto-switch pause and its own template rules
hold the curator back exactly as the account's hold back the account's.
A profile's curator is a Supporter feature (curator_profiles): turning one
on, or refreshing one, from a free account is 402 PLAN_REQUIRED. A profile
curator left on when the plan lapses is simply not run until it renews.
Curator object
GET /curator and every write return the same body:
{
"data": {
"enabled": true,
"cadence": "weekly",
"instructions": "no horror; family movie night on fridays",
"keepSystemRows": true,
"dayparts": false,
"templateId": 42,
"templateIsActive": true,
"liveTemplate": { "id": 42, "name": "AI Curator" },
"deferralReason": null,
"canActivate": false,
"claimPending": false,
"lastRunAt": "2026-09-20T03:14:07Z",
"nextRunAt": "2026-09-27T01:52:31Z",
"lastError": null,
"refreshing": false,
"refreshRemainingToday": 2,
"refreshWindow": "week",
"refreshLimit": 2,
"pausedUntil": null,
"userPlan": { "tier": "free", "features": [] },
"caps": { "maxRows": 12, "maxSmartLists": 6, "pinLimit": 9 },
"lane": { "profileId": null, "name": "Account", "kind": "account" },
"lanes": [
{ "profileId": null, "name": "Account", "kind": "account", "enabled": true },
{ "profileId": 31, "name": "Kids", "kind": "switchable", "enabled": false }
],
"plan": {
"id": 118,
"headline": "Autumn, and the shows you left half-finished",
"notes": null,
"status": "applied",
"createdAt": "2026-09-20T03:14:07Z",
"model": "claude-sonnet-5",
"upNextGroupId": "a9f3c2e1",
"rows": [
{ "kind": "group", "groupId": "10862192", "rowId": null,
"title": "Scene Releases: Movies", "alias": null,
"reason": "You watch new releases the week they land.", "feedback": null,
"required": false },
{ "kind": "smart_list", "groupId": "a1b2c3d4", "rowId": 17,
"title": "Slow-burn scares", "alias": null,
"reason": "You finished Hill House in three nights.", "feedback": "pinned",
"placement": "auto" }
]
}
}
}
templateIsActiveis not the same asenabled. A template rule, an auto-switch pause or a manual activation can leave the curator on while its homepage sits on the bench. That is what theapplied_deferredstatus means.liveTemplateis the template your account is serving right now ({ id, name }, ornullwhen none).deferralReasonsays why an enabled curator's template is not the live one:paused(an auto-switch pause),manual_choice_stands(you picked another template by hand),rule_match(one of your template rules matches right now),profile_lane_active(a switched-in profile's own curator holds the homepage) oraccount_paused(a profile's curator held by the account's pause);nullwhen it is live, off, or has no template yet.canActivateistruewhenPOST /curator/activatewould put the curator on your homepage.claimPendingistrueafter turning the curator on for the first time: its first plan takes the homepage over when it lands. On a profile's curator,liveTemplateisnullandcanActivate/claimPendingarefalse.planisnulluntil the first run lands. Itsrowsare joined to live state, so a row the user has since removed or vetoed is omitted: the list is what the homepage actually shows, not a snapshot of what was once planned.rowIdis present onsmart_listrows only;groupIdis present on both.placement(top|bottom|auto) is present only on a row whosefeedbackispinned: where that pin lands (seePOST /curator/feedback).userPlanis your account's plan ({ tier, features }), not the curator plan above (named apart for exactly that reason). On Supporter the curator may build a homepage with higher caps (server settings, default 16 rows / 8 AI-authored smart lists, against the free 12 / 6), can rundaily, and reads a weekly "what's on" brief. A Supporter account that lapses keepscadence: "daily"stored but is scheduled weekly until it renews, and may resend that storeddailyin aPATCHwithout a402(only a CHANGE to daily is gated).capsis THIS account's row budget:maxRowsandmaxSmartListsare what the curator plans to (and trims to),pinLimithow many groups it may keep (maxRows − 3). Read the numbers from here rather than hard-coding them: they are server settings and they follow the plan.keepSystemRows(defaulttrue) keeps your own auto-rows on every curated homepage: Up Next is first when Up Next is enabled and not vetoed (it is not part of the plan, so it does not appear inplan.rows; the plan'supNextGroupIdnames it,nullwhen there was no such slot), and your My Recommendations / Because You Watched rows are always included while that feature is switched on. The curator only picks where they go (required: trueon the row), and they do not count against the row cap. A recommendation row you switched off, or one frozen because a profile got its own, is an ordinary group again.falsetreats them all like any other group: the curator may use them or leave them out.dayparts(defaultfalse, Supporter:curator_dayparts) gives the homepage different rows for mornings and evenings. The curator then keeps TWO templates: "AI Curator — Day" (family, kids, light and short rows) and "AI Curator — Night" (the rest), sharing the same groups, plus two read-only curator-managed rules that switch between them at 05:00 and 17:00 in your timezone (GET /me→user.timezone; turningdaypartson needs one, see below). Plans made this way carrydaypart(morning|evening|any) on every row, and the run's status isapplied_dayparts(the curator wrote both halves; it refreshes whichever is live, and when neither is it puts on the half that fits the current hour).templateIsActiveis true when either half is live. A plan whose split leaves either half with fewer than two of the curator's own rows (pinned and required rows don't count), or whose halves come out identical, is written as ONE template with no rules. Turning it off folds the evening rows into the Day template (in plan order), moves whatever showed Night onto it and deletes Night straight away; so does turning the curator off, and losing Supporter (at the next run or scheduler sweep).pausedUntilis the account's auto-switch pause (same value as/template-rules/pause-status), because that pause is what defers a curator activation. On a profile's curator it is that profile's pause.laneis the curator this body describes:profileIdnullandkind: "account"for the account's, otherwise the profile's id, name andkind(bound|switchable).laneslists every curator you can address: the account first, then one per Stremio profile, each withenabled. On a profile's curator,keepSystemRowskeeps that profile's own recommendation rows; Up Next is account-level and not a fixed slot there.templateIsActivemeans the profile is pointed at its curated template (and, for the switchable profile switched in right now, that the account is serving it).lastRunAt,nextRunAt,pausedUntilandplan.createdAtare ISO-8601 UTC instants (2026-09-20T03:14:07Z). Parse them, don't pattern-match them.lastErroris a short fixed summary, not the provider's message: one of "The AI budget for today is used up.", "The AI took too long to answer." or "The last refresh didn't finish.". The underlying error stays in the server log;GET /curator/historyis where you look for which run failed.refreshRemainingTodayis the manual refreshes left in the current window. Despite the name, the window depends on your plan and is given byrefreshWindow:"day"for Supporter accounts (thecurator_dailyfeature; default 3 a day) or"week"otherwise (default 2 a week).refreshLimitis the window's total. The count resets on your account's local day, or its local ISO week (Monday start), the same boundary the cap itself is spent on (users.timezone, UTC if unset). The429is still the authority: the counter is read a moment before a concurrent run may spend from it.
Endpoints
| Method | Path | Scopes | Notes |
|---|---|---|---|
| GET | /curator |
templates:read |
The curator object above. Every route here also takes ?profile=<id> (see Per-profile curators) |
| PATCH | /curator |
templates:write |
Body: { enabled?, cadence?, instructions?, keepSystemRows?, dayparts?, timezone? }. Partial: omitted fields are preserved. Returns the curator object plus firstRunQueued (and daypartsRunQueued / pending; see below) |
| POST | /curator/refresh |
templates:write |
202 { "data": { "queued": true, "refreshRemainingToday": 2, "refreshWindow": "day", "refreshLimit": 3 } } |
| POST | /curator/feedback |
templates:write |
Body: { kind, rowId | groupId, feedback, placement? }. Returns the curator object |
| POST | /curator/activate |
templates:write |
Put the curator's template on your homepage now. No body. Returns the curator object. See below |
| GET | /curator/history |
templates:read |
Query: limit, an integer clamped to 1–50; anything else (a repeated parameter, a decimal, a word) falls back to the default 10 |
| GET | /curator/groups |
templates:read |
The "Always keep" picker: your groups, labelled. See below |
PATCH /curator
| Field | Type | Notes |
|---|---|---|
enabled |
boolean | Master switch. On a profile's curator (?profile=), turning it on while 8 other profiles already have theirs on is 409 CURATOR_LANE_LIMIT (details.limit), nothing saved; the account's own curator never counts toward that |
cadence |
enum | daily, weekly (default), monthly. daily needs Supporter: a free account moving to it gets 402 PLAN_REQUIRED (details.feature: "curator_daily") and nothing in the body is saved. Resending the cadence already stored is never refused, and a malformed body is a 400 before any 402 |
instructions |
string | null | Free text the model follows verbatim, max 500 characters. null or "" clears it |
keepSystemRows |
boolean | Keep Up Next first and your recommendation rows on every homepage (default true). null is a 400 |
dayparts |
boolean | Different rows for mornings and evenings (default false). Needs Supporter to turn on: 402 PLAN_REQUIRED (details.feature: "curator_dayparts"), nothing saved; resending the stored true or turning it off is never refused. Turning it on also needs your time zone: while GET /me shows timezone: null and the body carries no timezone, it is 409 TIMEZONE_REQUIRED, nothing saved. Turning it on for a curator that is on starts a plan now, charged to the refresh budget like the first run ("daypartsRunQueued": true; false when the budget is spent, and the next scheduled run builds the pair). Turning it off starts folding the pair back into one template immediately without waiting for it ("pending": true; refreshing stays true until it lands). null is a 400 |
timezone |
string | Your IANA time zone ("Europe/Berlin"), written to your account when it differs (the zone rules and the mornings/evenings switch run in). Validated against the server's time-zone database, max 64 characters; anything else is a 400 before anything is saved. null / absent leaves it alone |
Turning it on from off starts a plan in the background when you have
manual refresh budget left; "firstRunQueued": true says the kick started,
and refreshing is true with it. That kick is charged to the manual
refresh budget, exactly as a POST /curator/refresh would be.
When the budget is already spent the kick is skipped (you get
"firstRunQueued": false with "refreshRemainingToday": 0), and what
happens next depends on whether the curator has ever run for you:
- First-ever enable: the curator is due immediately, so the scheduler picks the first plan up on its next tick.
- Re-enable (it has run before): the schedule it already had stands. Turning it off and on again never makes the next plan come sooner. A schedule that had already come due is simply due again.
Either way the account is enabled and scheduled.
Turning the account's curator on also switches your homepage to it, the
same as picking its template by hand (it pauses auto-switching for 2 hours,
like any manual pick). The answer's homepageSwitch says how:
"now": the curator already had a template (you turned it back on), and it is live now."queued": as"now", but an update was in progress, so the switch waits for it and lands as soon as it finishes (claimPendingistrueuntil then; pollGET /curator)."first_plan": this is its first run, so there is no template yet; the first plan takes the homepage over when it lands (claimPendingistrueuntil then). Picking another template yourself, pausing auto-switching, one of your template rules switching templates, or adding or editing one of your rules before it lands cancels that; so does a day passing.null: nothing to switch (it was already live, it is a profile's curator, a Stremio profile is switched in on the account, or the request did not turn it on). With a profile switched in, the curator waits as it would for any template you picked (seedeferralReason); usePOST /curator/activateto switch anyway.
Turning it off only stops future scheduling. The managed template, the
groups it created and the rows it chose all stay exactly where they are
until you remove them, except a mornings/evenings pair, which is folded
back into the one template (its read-only rules deleted) so nothing keeps
switching for a curator that is off. A plan being applied at that moment
stops before it changes anything you can see. If one is mid-apply, the
switch is saved straight away and the fold waits for it: the answer carries
"pending": true and refreshing stays true until the fold lands.
POST /curator/activate
Puts the account curator's template on your homepage now, as if you had
picked it by hand (auto-switching pauses for 2 hours, like any manual pick,
but the curator's own next plan still goes live). With mornings and
evenings on, it picks the half that should be showing right now in your
time zone. Returns the curator object with templateIsActive: true; when
it was already live nothing changes. No model call and no refresh budget.
Refused with 409: CURATOR_NO_TEMPLATE before the first plan has landed
(or when the curator's template was deleted; its next plan makes a new
one), CURATOR_DISABLED when the curator is off,
CURATOR_ACTIVATE_ACCOUNT_ONLY with ?profile= (a profile's curator takes
over when the profile is switched in), CURATOR_PROFILE_LANE_ACTIVE while a
switched-in profile's own curator holds the homepage, and
TEMPLATE_GROUPS_NOT_SUBSET when the curator's template includes a group
you have since left (refresh the curator to rebuild it).
POST /curator/refresh
Re-plans now instead of waiting for the cadence. The response is 202 and
arrives in a second or two; the work itself (a model call, group creation,
smart-list syncs) runs for minutes afterwards. Poll GET /curator and
watch refreshing to see when it lands.
A curator that is switched off (enabled: false) can't be refreshed:
409 CURATOR_DISABLED, and nothing is spent. Besides each curator's own
allowance (refreshLimit), manual refreshes summed over the account and
all its profiles are capped at 10 a day with Supporter, otherwise 4 a week
(server defaults); past that every curator on the account answers
429 CURATOR_ACCOUNT_REFRESH_LIMIT, and refreshRemainingToday already
counts it.
POST /curator/feedback
{ "kind": "smart_list", "rowId": 17, "feedback": "pinned" }
{ "kind": "group", "groupId": "10862192", "feedback": "never" }
{ "kind": "smart_list", "rowId": 17, "feedback": "clear" }
{ "kind": "group", "groupId": "3d589d88", "feedback": "pinned", "placement": "bottom" }
feedback |
What it does |
|---|---|
pinned |
Keeps the row through every future re-plan. A pinned smart list is reused as-is, never recompiled. A pinned group the curator did not place itself goes at the top of the homepage, oldest keep first. This is "Always keep". A free account can keep at most 9 groups (Supporter: 13), which is maxRows − minRows of the plan's caps; one more is 409 CURATOR_PIN_LIMIT with the limit |
never |
Records the veto (the model is told not to propose it again) and takes the row off the homepage. For a smart_list row that is destructive and immediate: the private group is deleted (history-safe: your watch history survives). For a group row the template is rewritten and the membership left only if the curator is what joined you; if the curator has no homepage built yet, the veto is still recorded and nothing is removed until the next run |
clear |
Removes an earlier pinned or never mark |
placement (optional, only with feedback: "pinned") says where a pinned
row lands:
placement |
Where |
|---|---|
top |
First on the homepage (right after Up Next), whatever the curator chose. Several top pins keep the order you pinned them in. The default for a group |
bottom |
Last, below even your recommendation rows |
auto |
The curator places it; left out of a plan, it goes back where it was. The default for a smart-list row |
Omitted on a row that is already pinned, the placement is unchanged; omitted
on a new pin, it is the default. Re-sending pinned with a different
placement moves a kept group without counting as a new keep (it never hits
the CURATOR_PIN_LIMIT). Any other value (or a placement with never /
clear) is 400 CURATOR_BAD_PLACEMENT. A pinned row in plan.rows carries
its effective placement.
The two kinds are gated differently, and the refusal is the same
404 NOT_FOUND for both:
kind: "smart_list": therowIdmust be one of your own live curator rows. Somebody else's row, an already-removed one, or an id that is not a curator row at all is404. It does not have to still be in the current plan, so a row you can see is a row you can un-pin.kind: "group": thegroupIdmust be a group you belong to (the curator's own private shelves excepted; pin those assmart_listrows), in your current plan, or one you have already marked (so aclearkeeps working after the group leaves the plan). Any other group id is404, whether or not it exists.
The response is the full curator object, so read plan.rows back rather
than assuming what changed. A never that arrives while a plan is being
applied is recorded at once, but the removal waits for that apply to finish:
the answer then carries "pending": true and refreshing is true until
the row is gone.
GET /curator/groups
{ "data": { "groups": [
{ "id": "3d589d88", "name": "Korean cinema", "items": 64, "kind": "group", "pinned": true, "never": false, "placement": "top", "system": false },
{ "id": "a1b2c3d4", "name": "Up Next", "items": 6, "kind": "up_next", "pinned": false, "never": false, "placement": null, "system": true },
{ "id": "e5f6a7b8", "name": "My Recommendations", "items": 40, "kind": "recommendations", "pinned": false, "never": false, "placement": null, "system": true }
], "pinLimit": 9 } }
Every group you belong to except the curator's own private shelves, by name.
kind is group, up_next, recommendations or because_you_watched.
pinned means it is already on your "Always keep" list. Pin or clear it
with POST /curator/feedback { "kind": "group", "groupId", "feedback" }.
never means you vetoed it; clear that with feedback: "clear".
placement is where a kept group lands (top, bottom or auto; see
POST /curator/feedback); null when it is not kept.
system rows are your account's own LIVE Up Next and recommendation rows
(Up Next while it is enabled; a recommendation row while that feature is on
and its list is active); they are governed by keepSystemRows, not by
pinning. pinLimit is how many groups you may keep.
GET /curator/history
{ "data": { "plans": [
{ "id": 118, "headline": "Autumn, and the shows you left half-finished",
"status": "applied", "createdAt": "2026-09-20T03:14:07Z", "model": "claude-sonnet-5",
"counts": { "rows": 8, "added": 3, "retired": 2, "left": 0, "dropped": 1 } }
] } }
status is one of applied (the homepage changed and the template was
activated), applied_deferred (built, but a rule or a pause held the
activation back), applied_dayparts (Day and Night templates written; the
curator's own rules switch between them), rejected (too few rows survived validation, or an
upstream outage failed most of the new shelves, so the homepage was left
alone and the curator retries in a few hours), skipped_unchanged (nothing about you changed,
so no model call was made) and failed (a provider or budget error; see
lastError). Counts are zero for every status but the three applied ones,
which is the honest answer to "why did nothing change last Sunday?".
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | CURATOR_DISABLED |
The deployment has the AI curator off (AI_ENABLED / AI_CURATOR_ENABLED, or AI switched off by the site owner). Every endpoint in this section, including the reads |
| 403 | AI_BETA_ONLY |
The curator is in a closed beta and the key owner isn't in it. Every endpoint in this section, including the reads; nothing was read or changed. Except a PATCH that only switches enabled/dayparts off, which is carried out |
| 400 | INVALID_INPUT |
Bad cadence, non-string instructions, over 500 characters, non-boolean keepSystemRows or dayparts, bad kind/feedback, or a non-integer rowId |
| 402 | PLAN_REQUIRED |
PATCH with cadence: "daily" (error.details.feature curator_daily) or dayparts: true (curator_dayparts) on a free account; nothing was saved |
| 404 | NOT_FOUND |
That row is not yours, or that group is neither one you belong to nor in your current plan |
| 409 | CURATOR_BUSY |
A run is already in flight; wait for refreshing to go false. A scheduled run counts, so this can happen on a first click |
| 409 | CURATOR_DISABLED |
POST /curator/refresh or POST /curator/activate on a curator that is switched off. Nothing spent. (The 404 with this code is the whole feature being off; this 409 is only this curator's switch) |
| 409 | CURATOR_NO_TEMPLATE |
POST /curator/activate before the curator's first plan has landed, or after its template was deleted |
| 409 | CURATOR_ACTIVATE_ACCOUNT_ONLY |
POST /curator/activate?profile=<id>: only the account's curator can be switched in this way |
| 409 | CURATOR_PROFILE_LANE_ACTIVE |
POST /curator/activate while a switched-in profile's own curator holds the homepage (deferralReason: "profile_lane_active") |
| 409 | TEMPLATE_GROUPS_NOT_SUBSET |
POST /curator/activate when the curator's template includes a group you have since left. Refresh the curator to rebuild it, then switch again |
| 409 | CURATOR_LANE_LIMIT |
PATCH /curator?profile=<id> turning a profile's curator on while the most profiles allowed (8 by default) already have theirs on. error.details.limit is the cap; turn one off first. Nothing saved |
| 409 | CURATOR_PIN_LIMIT |
POST /curator/feedback pinning one more group than the Always-keep cap allows. error.details.limit is the cap (9 free, 13 Supporter); clear a pin first |
| 429 | CURATOR_REFRESH_LIMIT |
The manual-refresh cap: 3 a day with Supporter (curator_daily), otherwise 2 a week (server defaults). error.details is { refreshRemainingToday: 0, refreshWindow, refreshLimit } |
| 429 | CURATOR_ACCOUNT_REFRESH_LIMIT |
The account-wide cap: manual refreshes summed over the account's and every profile's curator, capped at 10 a day with Supporter, otherwise 4 a week (server defaults). error.details is the same as CURATOR_REFRESH_LIMIT's plus scope: "account", and refreshLimit is the account's cap |
| 500 | CURATOR_REFRESH_FAILED |
The refresh could not be started at all. Distinct from 409/429, which are refusals; this one is a fault worth reporting |
| 500 | CURATOR_ACTIVATE_FAILED |
POST /curator/activate could not switch the homepage. A fault worth reporting |
Starter packs#
Starter packs are admin-curated bundles of groups a user can subscribe to in one call, designed for onboarding. Subscribing creates (or updates) a pack-derived template named after the pack and activates it.
POST /api/v1/starter-packs/:id/subscribe
Scopes: starter_packs:subscribe
Body: none. :id is the pack's slug.
Side effects: joins you to the pack's public groups, creates/merges the pack-derived template (with the pack's per-group display settings), activates it (clearing any auto-switch pause), and pushes to Stremio Link if connected. Re-subscribing is idempotent and merges any groups added to the pack since.
Returns (note: camelCase keys):
{
"data": {
"templateId": 15,
"templateName": "Horror Essentials",
"added": 4,
"alreadyMember": 1,
"left": 0,
"skipped": [],
"errors": []
}
}
Errors: 404 STARTER_PACK_NOT_FOUND,
400 STARTER_PACK_HAS_NO_GROUPS,
400 STARTER_PACK_HAS_NO_ELIGIBLE_GROUPS (with details.skipped).
Listing available packs currently has no v1 endpoint; packs surface in the web app's onboarding flow.
Filter config reference#
Used by /discover/preview, /discover/execute, and smart_lists.filter_config.
{
// Core — required
mediaType: "movie" | "tv" | "both",
// Filters
genres: number[], // TMDB genre IDs; see GET /discover/genres
withoutGenres: number[],
yearRange: { from?: number; to?: number },
runtimeRange: { min?: number; max?: number }, // movies only
tmdbRating: { min?: number; max?: number }, // 0–10
voteCountMin: number,
imdbRating: { min?: number; max?: number }, // post-filter, see note
malRating: { min?: number; max?: number }, // anime mode only
originalLanguage: string[], // ISO 639-1: "en", "ja", ...
// Exact date windows (absolute YYYY-MM-DD; finer-grained than yearRange)
releaseDateGte: string, // movies: primary release date >=
releaseDateLte: string, // movies: primary release date <=
airDateGte: string, // tv: first air date >=
airDateLte: string, // tv: first air date <=
// Rolling window (relative; re-resolved on every sync, so it never goes stale)
releaseWindowDays: number, // 1-3650. movies: primary release date within the last N days;
// tv: ANY episode aired within the last N days (so returning
// seasons qualify). Overrides yearRange/releaseDateGte/airDateGte.
// Prefer this over a hard-coded yearRange for "new on X" lists.
// Watch providers
watchProviders: number[],
watchRegion: "US" | "GB" | ...,
monetizationType: ("flatrate" | "rent" | "buy" | "free" | "ads")[],
// People, keywords, networks, companies
withCast: number[], // TMDB person IDs
withCrew: number[],
withKeywords: number[], // NOTE: multiple keywords are ANDed
withoutKeywords: number[],
withNetworks: number[], // TV only
withCompanies: number[], // TMDB company IDs; see GET /discover/companies
withoutCompanies: number[],
// Certifications (movies)
certifications: string[], // "PG-13", "R"
certificationCountry: "US",
// Release type (movies)
releaseTypes: number[], // 1=Premiere 2=Limited 3=Theatrical 4=Digital 5=Physical 6=TV
// Advanced
productionCountries: string[], // ISO 3166-1
sortBy: string, // see below
excludeUncategorized: boolean,
// Anime mode (AniList) — see below
anilistStudios: string[],
anilistSource: string,
anilistTags: string[],
anilistSeason: string,
anilistSeasonYear: number,
anilistStatus: string
}
Array fields are lenient: a bare scalar ("originalLanguage": "en") is
coerced to a one-element array server-side, but sending proper arrays
is preferred.
sortBy values#
Standard TMDB sorts: popularity.desc (default), popularity.asc,
vote_average.desc, vote_average.asc, primary_release_date.desc,
primary_release_date.asc, revenue.desc, vote_count.desc.
Special values handled by Stremshare:
| Value | Behavior |
|---|---|
trending |
Fetches by popularity, then re-sorts by 7-day rising popularity delta: "what's blowing up" rather than "what's big" |
currently_airing |
TV only: shows currently on the air |
airing_today |
TV only: shows airing today |
imdbRating note#
IMDB ratings aren't a TMDB filter. This is applied server-side after
the TMDB fetch, using a locally maintained IMDB ratings dataset. Titles
with no known IMDB rating are excluded when the filter is set, so a
strict imdbRating.min on niche content can shrink results more than
expected.
Anime mode (AniList)#
A filter config is routed to AniList instead of TMDB when either:
originalLanguageincludes"ja"andgenresincludes16(Animation), or- any
anilist*field is set.
In anime mode the anilistStudios, anilistSource (e.g. MANGA,
LIGHT_NOVEL, ORIGINAL), anilistTags, anilistSeason
(WINTER/SPRING/SUMMER/FALL), anilistSeasonYear, and
anilistStatus (e.g. RELEASING, FINISHED) fields become available,
and malRating filtering applies. /discover/preview responses
report meta.source: "anilist" so you can confirm which engine ran.
Recipes#
Build a "Netflix" parent group with sub-themed smart lists#
KEY="ss_live_..."
BASE="https://stremshare.com/api/v1"
# 1. Create the parent group
GROUP=$(curl -s -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Netflix","description":"Curated Netflix collections"}' \
$BASE/groups | jq -r '.data.id')
# 2. Smart list: highly-rated dramas streaming on Netflix US
curl -s -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Netflix drama (7+)\",
\"filter_config\": {
\"mediaType\": \"movie\",
\"genres\": [18],
\"tmdbRating\": {\"min\": 7.0},
\"voteCountMin\": 500,
\"watchProviders\": [8],
\"watchRegion\": \"US\",
\"monetizationType\": [\"flatrate\"]
},
\"sync_frequency\": \"weekly\"
}" \
$BASE/groups/$GROUP/smart-lists
# 3. Sync it immediately (optional — it will also sync on schedule)
# (See response of step 2 for the smart list ID)
Find Netflix's provider ID with
GET /api/v1/discover/providers?region=US&mediaType=movie. Netflix is
usually 8.
Import a curated MDBList as a smart list#
curl -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "mdblist",
"url": "https://mdblist.com/lists/linaspurinis/top-watched-movies-of-the-week/",
"create_smart_list": true,
"sync_frequency": "daily",
"smart_list_name": "Top watched (weekly refresh)"
}' \
$BASE/groups/$GROUP_ID/import
Add a few hand-picked titles#
# Resolve a title to an IMDB id first if you don't know it:
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/discover/search?q=the+godfather&mediaType=movie"
curl -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"contentId": "tt0111161"},
{"contentId": "tt0068646"},
{"contentId": "tt0468569"}
]
}' \
$BASE/groups/$GROUP_ID/content
Enable a group in the addon and put it first#
# Fetch current settings
CURRENT=$(curl -s -H "Authorization: Bearer $KEY" $BASE/addon-settings)
# Merge — PATCH is partial-safe
curl -X PATCH -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled_groups": ["<new-group-id>", "<existing-1>", "<existing-2>"],
"group_order": ["<new-group-id>", "<existing-1>", "<existing-2>"]
}' \
$BASE/addon-settings
A Halloween addon that switches itself on#
# 1. Save your current setup so you can come back to it
curl -s -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name":"Everyday"}' $BASE/templates
# 2. Build a horror-only template (groups must be ones you're a member of)
TPL=$(curl -s -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name":"Halloween","config":{"enabled_groups":["<horror-group-1>","<horror-group-2>"]}}' \
$BASE/templates | jq -r '.data.id')
# 3. Auto-activate it from two weeks before Halloween through Nov 1
curl -s -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{
\"template_id\": $TPL,
\"rule_type\": \"holiday\",
\"config\": { \"holiday_id\": \"us_halloween\", \"days_before\": 14, \"days_after\": 1 }
}" \
$BASE/template-rules
# 4. See when the next automatic switch will happen
curl -s -H "Authorization: Bearer $KEY" $BASE/template-rules/next-switch
When the window ends, the scheduler switches back to the
highest-priority matching rule's template. Add a low-priority
day_of_week rule covering all days to your "Everyday" template to
make the fallback explicit.
Troubleshooting#
401 NO_API_KEY#
Header missing or not Bearer. Expected: Authorization: Bearer ss_live_....
401 INVALID_API_KEY#
Key doesn't exist, was revoked, or expired. Create a new one.
403 BETA_ONLY#
Your account isn't beta-enabled. The whole /api/v1 surface is behind
the beta flag, so contact the site owner.
403 AI_BETA_ONLY#
The AI Curator is in a closed beta and your account isn't in it yet
(GET /me → data.ai.curator is beta). This is separate from the API
beta above; the site owner grants it.
403 INSUFFICIENT_SCOPE#
The response details block lists required vs. granted scopes. Edit
the key's scopes in the UI (Account → API Keys → the key's row) or
create a new key with more scopes.
403 NOT_MEMBER / NOT_CONTRIBUTOR#
Scope is fine, but the key's owner doesn't have the required group role. Adjust membership via the web UI; roles can't be self-promoted via the API.
429 RATE_LIMITED#
You hit the per-key limit (see Rate limits). Wait the
number of seconds in the Retry-After header (also echoed as
details.retry_after) and retry. If you're only hitting it on
/discover/preview, /discover/execute, imports, or smart-list sync,
that's the stricter expensive-endpoint budget, so batch or space out
those calls.
Imports silently return 0 items#
- Verify the URL is a public list you can load in an incognito browser.
- For Trakt private lists, the user must have connected Trakt via the web UI first.
- IMDB lists can't be imported at all any more. See IMDB imports are retired.
Smart list never auto-syncs#
- Check
is_active,sync_frequency(can't bemanual), andnext_sync_at. The sync scheduler runs every 15 minutes. consecutive_failuresof 3+ pauses automatic syncs; trigger a manual sync to clear it.
Smart list results shrink when adding imdbRating#
Titles with no known IMDB rating are excluded once the filter is set; see the filter config note.
Template edits return 409 TEMPLATE_IS_PACK_DERIVED#
Templates created by starter-pack subscribe are managed by the pack.
POST /templates/:id/duplicate gives you an editable copy.
How do I rotate a key?#
There's no in-place rotation. Create a new key, update the caller, revoke the old one in the UI.