Pages (VOD)
Sync published pages and rails to your storefront, and build, edit and publish storefront pages.
A page is a storefront screen (home, movies, kids …). It holds ordered shelves (also called rails or rows), and each shelf holds an ordered list of titles. There are two halves to this API:
- Sync: read what is published, to render your storefront. This needs only the
feeds:readscope. - Edit: build pages and shelves, let AI suggest titles, pass the publish gate and publish. This needs
vod:readandvod:write.
Rules the server enforces
- Published means gated. Everything you read from
pages.jsonand the rail files is the last published version. Edits to a page are a draft until you publish again, and a blocking validation gate runs on every publish. - Expired rights block publishing. A page that contains a title whose rights have expired can't be published.
- Locked tiles are immutable to AI. Rebuilds, refreshes and substitutions keep tiles you've locked.
- AI suggestions are proposals.
suggestreturns candidates and adds nothing until you add them.
Sync page data to your storefront
Read the index
GET /v1/pages.json lists every published page and, for each, its rails.
curl -i https://dev.app.ingenee.ai/api/v1/pages.json \
-H "Authorization: Bearer $INGENEE_API_KEY"HTTP/1.1 200 OK
content-type: application/json
etag: W/"9b2f4c1e7a30"
cache-control: public, max-age=300
last-modified: Thu, 29 Oct 2026 18:36:29 GMT{
"version": 1,
"count": 1,
"shelfCount": 2,
"pages": [
{
"slug": "home",
"name": "Home",
"pageType": "home",
"region": "US",
"feed": "https://cdn.ingenee.ai/storefronts/home.json",
"shelves": [
{ "slug": "friday-night-thrillers", "name": "Friday night thrillers", "tiles": 12, "rail": "https://cdn.ingenee.ai/rails/home/friday-night-thrillers.json" },
{ "slug": "new-this-week", "name": "New this week", "tiles": 10, "rail": "https://cdn.ingenee.ai/rails/home/new-this-week.json" }
]
}
]
}Only published pages appear, and tiles counts what is being served now. A seasonal shelf outside its dates is left out
until its window opens.
Cache with If-None-Match
Store the etag (a weak validator such as W/"9b2f4c1e7a30") and send it back exactly as received in If-None-Match. An unchanged index costs a 304 with no body.
curl -i https://dev.app.ingenee.ai/api/v1/pages.json \
-H "Authorization: Bearer $INGENEE_API_KEY" -H 'If-None-Match: W/"9b2f4c1e7a30"'HTTP/1.1 304 Not ModifiedThe etag is computed from the exact bytes served, so the same content always has the same etag.
Fetch the rails that changed
Build each rail request from the page slug and shelf slug in the index:
GET /v1/rails/{pageSlug}/{shelfSlug}.json. Rail files support If-None-Match the same way.
curl https://dev.app.ingenee.ai/api/v1/rails/home/friday-night-thrillers.json \
-H "Authorization: Bearer $INGENEE_API_KEY"{
"version": 1,
"page": "home",
"shelf": { "slug": "friday-night-thrillers", "name": "Friday night thrillers", "audienceFit": null },
"count": 12,
"items": [
{
"position": 1,
"id": "1b478b6a-7721-4a94-aade-55c5658f259c",
"title": "The Long Night",
"year": 2019,
"rating": "TV-14",
"poster": "https://image.tmdb.org/t/p/w500/…",
"backdrop": "https://image.tmdb.org/t/p/w1280/…",
"description": "A night shift turns into a manhunt.",
"runtimeMin": 94,
"genres": ["Thriller"]
}
]
}items[].id is the title id, so GET /v1/titles/{id} returns everything else. Tiles whose title has been trashed are left out.
404 ENDPOINT_NOT_FOUND means no such page or shelf. 409 FEED_DRAFT means the page exists but isn't published.
const base = 'https://dev.app.ingenee.ai/api/v1';
const headers = { Authorization: `Bearer ${process.env.INGENEE_API_KEY}` };
const etags = new Map<string, string>();
async function get(path: string) {
const res = await fetch(`${base}${path}`, {
headers: { ...headers, ...(etags.has(path) ? { 'If-None-Match': etags.get(path)! } : {}) },
});
if (res.status === 304) return null; // unchanged since last time
etags.set(path, res.headers.get('etag')!);
return res.json();
}
const index = await get('/pages.json');
for (const page of index?.pages ?? []) {
for (const shelf of page.shelves) {
const rail = await get(`/rails/${page.slug}/${shelf.slug}.json`);
if (rail) await saveRail(page.slug, shelf.slug, rail.items);
}
}If pages.json returns 304, nothing on any page changed and you can skip the rails. Changes appear after someone
publishes, and a refetch on a schedule is fine: the index is cached for five minutes and rails for an hour. Stay inside the
rate limit of 20 requests per second when you fetch many rails.
The index points at the delivery host
feed and rail in the index are absolute URLs on Ingenee's delivery host. To read the data with your API key, use the
slug values with the /v1 paths shown here.
Storefront feeds (a whole page in one call)
If you'd rather fetch a page in one request, use the storefront feeds. They take the storefront id (from
GET /v1/storefronts), not the slug.
| Feed | Call | Notes |
|---|---|---|
| JSON | GET /v1/storefronts/{id}/feed.json?state=published | The page, ordered shelves, ordered tiles and the hero banner |
| MRSS | GET /v1/storefronts/{id}/feed.mrss?state=published | Media RSS 2.0, with the shelf name as the category |
state defaults to draft, the working copy shown in the editor. For your live storefront always send
state=published. If the page has never been published it returns "published": false and no shelves. These two feeds don't
send an ETag; use pages.json to detect changes.
{
"version": 1,
"state": "published",
"published": true,
"page": { "slug": "home", "name": "Home", "region": "US", "pageType": "home", "updatedAt": "2026-10-29T18:36:29.063Z" },
"shelves": [
{ "id": "…", "name": "Friday night thrillers", "orientation": "landscape", "audienceFit": null,
"items": [ { "id": "1b478b6a-…", "title": "The Long Night", "year": 2019, "rating": "TV-14", "poster": "…", "backdrop": "…", "description": "…", "runtimeMin": 94, "genres": ["Thriller"] } ] }
],
"banner": null
}The feed applies active promotions (hero pins, boosts, sponsored shelves) and your cross-row duplicate policy when it is read.
Create a page and fill it
Create the page
curl -X POST https://dev.app.ingenee.ai/api/v1/storefronts \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{ "name": "Kids", "pageType": "home", "region": "US" }'{ "id": "b83f0a6c-2d14-4e75-9c1a-5f6e7d8a9b01" }name is required. pageType defaults to home, contentType to mixed, region to US and refreshCadence to
weekly. The page's slug is made from the name and made unique (kids, kids-2 …). GET /v1/storefronts lists pages
with status (draft, published, stale, paused) and counts.
Add a shelf
curl -X POST https://dev.app.ingenee.ai/api/v1/storefronts/b83f0a6c-2d14-4e75-9c1a-5f6e7d8a9b01/shelves \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{ "name": "Cartoon classics", "concept": "Hand-drawn family animation from the 1980s and 90s", "refreshPolicy": "light" }'201 with { "id": "…" }. Optional fields: ruleClass, concept (the idea AI works from), audienceFit,
requiredTags, excludedAssetIds, refreshPolicy (light, moderate, aggressive), crossRowDedup (strict,
relaxed), orientation (portrait, landscape), sponsorId and disclosureText. A plan limit on rails can return 403 FORBIDDEN.
PATCH /v1/storefronts/{id}/shelves/reorder takes { "order": ["shelfId", …] }.
Ask AI for candidates, then add the ones you want
curl -X POST https://dev.app.ingenee.ai/api/v1/shelves/SHELF_ID/suggest \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{ "concept": "Hand-drawn family animation from the 1980s and 90s", "size": 20 }'{
"concept": "Hand-drawn family animation from the 1980s and 90s",
"candidates": [ { "assetId": "…", "title": "Captain Comet", "year": 1991, "posterUrl": "…", "score": 0.81, "reason": "cosine 0.81" } ],
"signals": { "cohesion": 0.74, "cosineP50": 0.72, "cosineP90": 0.8, "pool": 20, "genreWeights": [ { "name": "Animation", "weight": 0.9 } ] }
}Candidates are ranked by similarity to your concept and exclude titles already on the shelf and titles marked off-rotation. Add the ones you accept:
curl -X POST https://dev.app.ingenee.ai/api/v1/shelves/SHELF_ID/items \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{ "assetIds": ["…", "…"] }'201 with { "added": 2, "skipped": [], "allowedKinds": ["movie", "series", "episode"] }. A title of the wrong kind for the
shelf (a movie on a music shelf) is skipped with a reason; if every title is rejected the call returns 422 CONTENT_KIND.
Order, lock, top up
PATCH /v1/shelves/{id}/items/reordertakes{ "order": [shelfItemIds], "locked": { "<shelfItemId>": true } }. The ids are the shelf item ids fromGET /v1/storefronts/{id}, not title ids. Locked tiles are kept through rebuilds.POST /v1/shelves/{id}/items/{itemId}/deleteremoves a tile.POST /v1/shelves/{id}/autofilltops the shelf up to its minimum using titles similar to what's already on it. It needs at least one title on the shelf (409otherwise) and returns{ added, total }.POST /v1/shelves/{id}/refresh?mode=light|moderate|aggressiveswaps some unlocked tiles for fresh ones; the default mode is the shelf'srefreshPolicy.PATCH /v1/shelves/{id}changes any shelf setting, including a seasonal window withwindowStartandwindowEnd(ISO timestamps; outside the window the shelf isn't served).status: "live"is refused (409 BLOCKED) if the shelf is under its minimum or sponsored without disclosure text.POST /v1/shelves/{id}/deleteremoves a shelf.
Build a whole page with AI
POST /v1/storefronts/{id}/build regenerates shelves, keeping locked tiles. Send { "shelfIds": [...] } to limit it and
"mode": "refresh" to refresh rather than rebuild. It returns 202 and a build to poll.
{ "buildId": "d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f80", "total": 6, "mode": "rebuild" }curl https://dev.app.ingenee.ai/api/v1/storefronts/b83f0a6c-2d14-4e75-9c1a-5f6e7d8a9b01/builds/d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f80 \
-H "Authorization: Bearer $INGENEE_API_KEY"status is running, then done or completed_with_errors (one shelf failing doesn't stop the others). The response has
percent, counts (done, inflight, queued, failed) and one entry per shelf in jobs, each with its tiles,
swaps and any error. A build with no shelves returns 409 CONFLICT.
Check the gate and publish
GET /v1/storefronts/{id}/validate runs the publish gate without publishing anything. Add ?shelf={shelfId} to check one shelf.
{
"blockers": [
{ "check": "“Cartoon classics” shelf is incomplete", "detail": "Only 3 of the minimum 8 tiles — needs 5 more before publish" },
{ "check": "1 title(s) with expired rights", "detail": "Out of window: Captain Comet" }
],
"warnings": [ { "check": "2 tile(s) missing a poster", "detail": "A fallback image will be used." } ],
"passing": ["Page has 4 shelves"],
"counts": { "blockers": 2, "warnings": 1, "passing": 1 },
"canPublish": false
}Blockers are: no shelves, a shelf under its minimum tile count, a sponsored shelf with no disclosure text, and titles with
expired rights. Warnings (titles not fully enriched, rights expiring within 30 days, duplicate or over-used titles, missing
posters, seasonal shelves not currently serving) don't stop a publish. Each item can carry fix suggestions. The response also
includes diff against what's live.
To publish, set the status. This runs the same gate and, if it passes, takes an immutable snapshot and makes it the live version:
curl -X PATCH https://dev.app.ingenee.ai/api/v1/storefronts/b83f0a6c-2d14-4e75-9c1a-5f6e7d8a9b01 \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{ "status": "published" }'{ "id": "b83f0a6c-2d14-4e75-9c1a-5f6e7d8a9b01", "updated": ["status"], "publishedVersion": 3 }{ "error": { "code": "BLOCKED", "message": "Cannot publish — 2 blocker(s) must be fixed first", "details": {} } }The BLOCKED response only counts the blockers. Call validate to see them. After you change a published page, send
status: "published" again to put the changes live; until then pages.json and the rails keep serving the last published version.
PATCH also renames a page and changes pageType, contentType, region and refreshCadence. status: "paused"
takes it off the published index.
Versions and rollback
Every publish is kept as a version.
curl https://dev.app.ingenee.ai/api/v1/storefronts/b83f0a6c-2d14-4e75-9c1a-5f6e7d8a9b01/versions \
-H "Authorization: Bearer $INGENEE_API_KEY"{ currentVersionId, versions: [{ id, versionNum, isCurrent, shelfCount, tileCount, added, removed, moved, publishedBy, publishedAt }] },
newest first. GET …/versions/{vid} returns one snapshot. POST …/versions/{vid}/restore replaces the page's working
shelves with that snapshot and sets the page to draft; it returns { "restored": 2, "status": "draft" }. Nothing goes live until
you publish through the gate again.
Not yet available
- Reordering pages, setting the hero banner, and the AI substitution of at-risk tiles. These are console features.
- A webhook for page publishes. The available events cover channel schedules, Studio and imports.
Endpoints
| Operation | Method + path | Scope |
|---|---|---|
| Index of published pages and rails | GET /v1/pages.json | feeds:read |
| One published rail | GET /v1/rails/{pageSlug}/{shelfFile} | feeds:read |
| Storefront JSON feed | GET /v1/storefronts/{id}/feed.json | feeds:read |
| Storefront MRSS feed | GET /v1/storefronts/{id}/feed.mrss | feeds:read |
| List storefront pages | GET /v1/storefronts | vod:read |
| Create a storefront page | POST /v1/storefronts | vod:write |
| Get a page with its shelves | GET /v1/storefronts/{id} | vod:read |
| Update or publish a page | PATCH /v1/storefronts/{id} | vod:write |
| Delete a page | POST /v1/storefronts/{id}/delete | vod:write |
| Run the publish gate | GET /v1/storefronts/{id}/validate | vod:read |
| Build a page with AI | POST /v1/storefronts/{id}/build | vod:write |
| Get a build | GET /v1/storefronts/{id}/builds/{buildId} | vod:read |
| List published versions | GET /v1/storefronts/{id}/versions | vod:read |
| Get a published version | GET /v1/storefronts/{id}/versions/{vid} | vod:read |
| Restore a version | POST /v1/storefronts/{id}/versions/{vid}/restore | vod:write |
| Add a shelf | POST /v1/storefronts/{id}/shelves | vod:write |
| Reorder shelves | PATCH /v1/storefronts/{id}/shelves/reorder | vod:write |
| Update a shelf | PATCH /v1/shelves/{id} | vod:write |
| Delete a shelf | POST /v1/shelves/{id}/delete | vod:write |
| Add titles to a shelf | POST /v1/shelves/{id}/items | vod:write |
| Remove a title from a shelf | POST /v1/shelves/{id}/items/{itemId}/delete | vod:write |
| Reorder a shelf | PATCH /v1/shelves/{id}/items/reorder | vod:write |
| Suggest titles for a shelf | POST /v1/shelves/{id}/suggest | vod:write |
| Top up a shelf | POST /v1/shelves/{id}/autofill | vod:write |
| Refresh a shelf | POST /v1/shelves/{id}/refresh | vod:write |