Stremshare External API (/api/v1)

On this page

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 HTML
  • https://stremshare.com/docs/api.md: raw markdown (ideal for pasting into an AI assistant or fetching programmatically)

Contents#

  1. Quick start
  2. Authentication
  3. Scopes
  4. Conventions
  5. Endpoints
  6. Filter config reference
  7. Recipes
  8. Troubleshooting

Quick start#

  1. Log into the web app.

  2. Go to Account → API Keys → New API Key.

  3. Give it a name and select scopes (use "Select all" for a Claude-style key).

  4. Copy the raw key. It's shown exactly once.

  5. Verify:

    curl -H "Authorization: Bearer ss_live_..." \
      https://stremshare.com/api/v1/me
    

    You 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/search to 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) or replace (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. imdb is no longer accepted (see IMDB imports).
  • page_progression_enabled: when true, 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:

  1. Search MDBList for a mirror of the list and import that instead.
  2. 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_groups must only contain groups you are a member of, else 409 TEMPLATE_GROUPS_NOT_SUBSET (with details.missing_group_ids). group_order is 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 (or group_order, addon_group_order, curator_group_name_overrides, group_name_overrides) → 409 TEMPLATE_IS_CURATOR, with the offending keys in details.fields; nothing is written. A changed name is refused the same way (name appears in details.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 of 400 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" }
      ]
    }
  }
}
  • templateIsActive is not the same as enabled. 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 the applied_deferred status means.
  • liveTemplate is the template your account is serving right now ({ id, name }, or null when none). deferralReason says 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) or account_paused (a profile's curator held by the account's pause); null when it is live, off, or has no template yet. canActivate is true when POST /curator/activate would put the curator on your homepage. claimPending is true after turning the curator on for the first time: its first plan takes the homepage over when it lands. On a profile's curator, liveTemplate is null and canActivate / claimPending are false.
  • plan is null until the first run lands. Its rows are 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.
  • rowId is present on smart_list rows only; groupId is present on both.
  • placement (top | bottom | auto) is present only on a row whose feedback is pinned: where that pin lands (see POST /curator/feedback).
  • userPlan is 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 run daily, and reads a weekly "what's on" brief. A Supporter account that lapses keeps cadence: "daily" stored but is scheduled weekly until it renews, and may resend that stored daily in a PATCH without a 402 (only a CHANGE to daily is gated).
  • caps is THIS account's row budget: maxRows and maxSmartLists are what the curator plans to (and trims to), pinLimit how 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 (default true) 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 in plan.rows; the plan's upNextGroupId names it, null when 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: true on 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. false treats them all like any other group: the curator may use them or leave them out.
  • dayparts (default false, 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; turning dayparts on needs one, see below). Plans made this way carry daypart (morning | evening | any) on every row, and the run's status is applied_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). templateIsActive is 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).
  • pausedUntil is 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.
  • lane is the curator this body describes: profileId null and kind: "account" for the account's, otherwise the profile's id, name and kind (bound | switchable). lanes lists every curator you can address: the account first, then one per Stremio profile, each with enabled. On a profile's curator, keepSystemRows keeps that profile's own recommendation rows; Up Next is account-level and not a fixed slot there. templateIsActive means 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, pausedUntil and plan.createdAt are ISO-8601 UTC instants (2026-09-20T03:14:07Z). Parse them, don't pattern-match them.
  • lastError is 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/history is where you look for which run failed.
  • refreshRemainingToday is the manual refreshes left in the current window. Despite the name, the window depends on your plan and is given by refreshWindow: "day" for Supporter accounts (the curator_daily feature; default 3 a day) or "week" otherwise (default 2 a week). refreshLimit is 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). The 429 is 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 (claimPending is true until then; poll GET /curator).
  • "first_plan": this is its first run, so there is no template yet; the first plan takes the homepage over when it lands (claimPending is true until 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 (see deferralReason); use POST /curator/activate to 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": the rowId must 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 is 404. 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": the groupId must be a group you belong to (the curator's own private shelves excepted; pin those as smart_list rows), in your current plan, or one you have already marked (so a clear keeps working after the group leaves the plan). Any other group id is 404, 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:

  • originalLanguage includes "ja" and genres includes 16 (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 be manual), and next_sync_at. The sync scheduler runs every 15 minutes.
  • consecutive_failures of 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.