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:read scope.
  • Edit: build pages and shelves, let AI suggest titles, pass the publish gate and publish. This needs vod:read and vod:write.

Rules the server enforces

  • Published means gated. Everything you read from pages.json and 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. suggest returns 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
200 OK (trimmed)
{
  "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 Modified

The 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"
200 OK (trimmed)
{
  "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.

FeedCallNotes
JSONGET /v1/storefronts/{id}/feed.json?state=publishedThe page, ordered shelves, ordered tiles and the hero banner
MRSSGET /v1/storefronts/{id}/feed.mrss?state=publishedMedia 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.

200 OK (trimmed)
{
  "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" }'
201 Created
{ "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 }'
200 OK (trimmed)
{
  "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/reorder takes { "order": [shelfItemIds], "locked": { "<shelfItemId>": true } }. The ids are the shelf item ids from GET /v1/storefronts/{id}, not title ids. Locked tiles are kept through rebuilds.
  • POST /v1/shelves/{id}/items/{itemId}/delete removes a tile.
  • POST /v1/shelves/{id}/autofill tops the shelf up to its minimum using titles similar to what's already on it. It needs at least one title on the shelf (409 otherwise) and returns { added, total }.
  • POST /v1/shelves/{id}/refresh?mode=light|moderate|aggressive swaps some unlocked tiles for fresh ones; the default mode is the shelf's refreshPolicy.
  • PATCH /v1/shelves/{id} changes any shelf setting, including a seasonal window with windowStart and windowEnd (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}/delete removes 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.

202 Accepted
{ "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.

200 OK (trimmed)
{
  "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" }'
200 OK
{ "id": "b83f0a6c-2d14-4e75-9c1a-5f6e7d8a9b01", "updated": ["status"], "publishedVersion": 3 }
409 Conflict
{ "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

OperationMethod + pathScope
Index of published pages and railsGET /v1/pages.jsonfeeds:read
One published railGET /v1/rails/{pageSlug}/{shelfFile}feeds:read
Storefront JSON feedGET /v1/storefronts/{id}/feed.jsonfeeds:read
Storefront MRSS feedGET /v1/storefronts/{id}/feed.mrssfeeds:read
List storefront pagesGET /v1/storefrontsvod:read
Create a storefront pagePOST /v1/storefrontsvod:write
Get a page with its shelvesGET /v1/storefronts/{id}vod:read
Update or publish a pagePATCH /v1/storefronts/{id}vod:write
Delete a pagePOST /v1/storefronts/{id}/deletevod:write
Run the publish gateGET /v1/storefronts/{id}/validatevod:read
Build a page with AIPOST /v1/storefronts/{id}/buildvod:write
Get a buildGET /v1/storefronts/{id}/builds/{buildId}vod:read
List published versionsGET /v1/storefronts/{id}/versionsvod:read
Get a published versionGET /v1/storefronts/{id}/versions/{vid}vod:read
Restore a versionPOST /v1/storefronts/{id}/versions/{vid}/restorevod:write
Add a shelfPOST /v1/storefronts/{id}/shelvesvod:write
Reorder shelvesPATCH /v1/storefronts/{id}/shelves/reordervod:write
Update a shelfPATCH /v1/shelves/{id}vod:write
Delete a shelfPOST /v1/shelves/{id}/deletevod:write
Add titles to a shelfPOST /v1/shelves/{id}/itemsvod:write
Remove a title from a shelfPOST /v1/shelves/{id}/items/{itemId}/deletevod:write
Reorder a shelfPATCH /v1/shelves/{id}/items/reordervod:write
Suggest titles for a shelfPOST /v1/shelves/{id}/suggestvod:write
Top up a shelfPOST /v1/shelves/{id}/autofillvod:write
Refresh a shelfPOST /v1/shelves/{id}/refreshvod:write

On this page