Content Changes API#
The content changes feed lists the items in your tenant whose content has changed, in the order the changes were recorded. Use it to keep your own copy of Stories, Clips and related content in step with Storyteller without reading everything again: read the changes after your cursor, fetch the current state of each changed item, save the new cursor, and repeat.
This page describes version 1 of the feed.
Endpoint Summary#
| Endpoint | Description |
|---|---|
GET /api/changes |
Items whose content changed after a cursor, the cursor to read from next, and the feed's status |
How It Works#
- Every save that changes what an integration can read about an item records that item in Storyteller's change register, in the same transaction as the save.
- The register keeps one entry per item. A later change to the same item replaces its entry and moves it to the end of the feed, so several changes to one item between two of your reads arrive as one, with the item's latest state.
- The feed returns entries after your cursor, in the order they were recorded. A change takes its place when it is written, and the feed holds it back until every change before it has committed, so nothing can later appear behind a cursor it has given you. Changes to different items can therefore arrive in a different order from the one they committed in.
- An item says which thing changed and how it now stands. It does not carry the content: read that from the item's own endpoint (see Item Types).
Get Content Changes (GET /api/changes)#
GET /api/changes?cursor={cursor}&types={types}&maxResultCount={maxResultCount}
x-storyteller-api-key: your-api-key-here
Send your Server App's API key in the x-storyteller-api-key header, as for every Integrations API endpoint (see Authentication). The feed is always your own tenant's: the tenant comes from the key.
Query Parameters#
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
cursor |
string | No | – | Where to read from. Omit it to start watching from now: the response has no items, and its cursor is the newest position. Use beginning to read every item from the start. Otherwise pass back a cursor or head this endpoint returned for your tenant. |
types |
string | No | every type | The item types to return, separated by commas, for example story,clip. Values are case-insensitive; spaces around them are ignored, and so are repeats. See Item Types and Filtering by Type. |
maxResultCount |
integer | No | 100 |
The most items to return, from 1 to 200. |
Any other query parameter, a parameter given twice, an empty cursor, an unknown or empty type, or a maxResultCount outside 1 to 200 returns 400 Bad Request. So does a cursor that is malformed, was issued for another tenant, or is ahead of the feed (see Versions for what is checked).
Successful responses carry Cache-Control: private, no-store.
Example Request#
GET /api/changes?cursor=AqyzQvJMoRxaAAAAAAAsmj8AAAAAACyaAA&maxResultCount=3
x-storyteller-api-key: your-api-key-here
Example Response#
{
"items": [
{
"type": "story",
"id": "11111111-1111-4111-8111-111111111111",
"version": "00000000002C9A41",
"state": "expired",
"status": "Published",
"changedAt": "2026-10-20T09:14:03.4721836Z"
},
{
"type": "clip",
"id": "22222222-2222-4222-8222-222222222222",
"version": "00000000002C9A4C",
"state": "scheduled",
"status": "Published",
"changedAt": "2026-10-20T09:14:05.0918273Z"
},
{
"type": "mediaAsset",
"id": "33333333-3333-4333-8333-333333333333",
"version": "00000000002C9A57",
"state": "available",
"changedAt": "2026-10-20T09:14:06.6204415Z"
}
],
"cursor": "AqyzQvJMoRxaAAAAAAAsmlcAAAAAACyaAA",
"head": "AqyzQvJMoRxaAAAAAAAsmmEAAAAAACyaYg",
"hasMore": true,
"status": {
"registerWrites": "on",
"registerWritesChangedAt": "2026-10-14T00:00:00Z",
"repairPending": false
}
}
Response Fields#
| Field | Type | Description |
|---|---|---|
items |
array | The changed items after your cursor, oldest change first. Empty when nothing has changed. |
cursor |
string | Opaque. Where to read from next: send it as cursor with your next request. Save it only once you have processed the page (see Reading the Feed). |
head |
string | Opaque. The newest position that is safe to read from now. A client starting fresh can save it and read from it later to see every change made since. |
hasMore |
boolean | true when more changes were already waiting: read again at once. false when you have caught up: keep polling (see Polling). |
status |
object | Whether the feed is complete for your tenant (see Register Status). |
Item Object
| Field | Type | Description |
|---|---|---|
type |
string | The item's type: story, clip, mediaAsset, play, category, collection or actionLink. |
id |
string (GUID) | The item's Storyteller id. |
version |
string | Opaque. Changes every time the item is recorded. See Versions. |
state |
string | The item's effective state when it was last recorded. See States and Statuses. |
status |
string | Stories and Clips only: the editorial status Storyteller stores. Omitted for other types, and for a Story or Clip that no longer exists. |
changedAt |
string (ISO 8601) | When the item was recorded, in UTC with Z. For information only: it is not the commit time and does not order the feed. |
Status Object
| Field | Type | Description |
|---|---|---|
registerWrites |
string | on, or off while Storyteller has switched off recording changes. See Register Status. |
registerWritesChangedAt |
string (ISO 8601) | When registerWrites last changed, in UTC with Z. |
repairPending |
boolean | true while a recent save's changes are waiting to be recorded. They are usually recorded within a minute. |
Usage Examples#
Each example raises a cursor-expired error on 410 Gone. When you catch it, read again from cursor=beginning, and once you have caught up, remove from your copy any item that read did not list (see Cursor Lifetime).
import fetch from 'node-fetch';
const baseUrl = 'https://integrations.usestoryteller.com';
// The saved cursor has expired: read again from 'beginning'.
export class CursorExpiredError extends Error {}
// Reads every change after the saved cursor, then returns the cursor to save.
export async function readChanges(apiKey, cursor, handleItem) {
for (;;) {
const params = new URLSearchParams({ cursor, maxResultCount: '200' });
const response = await fetch(`${baseUrl}/api/changes?${params}`, {
headers: { 'x-storyteller-api-key': apiKey }
});
if (response.status === 410) {
throw new CursorExpiredError();
}
if (!response.ok) {
const problem = await response.json();
throw new Error(`${problem.title}: ${problem.detail}`);
}
const page = await response.json();
for (const item of page.items) {
await handleItem(item);
}
cursor = page.cursor; // save this durably before reading on
if (!page.hasMore) {
return cursor; // caught up: wait at least 30 seconds before calling again
}
}
}
import requests
BASE_URL = 'https://integrations.usestoryteller.com'
class CursorExpired(Exception):
"""The saved cursor has expired: read again from 'beginning'."""
def read_changes(api_key: str, cursor: str, handle_item) -> str:
"""Reads every change after the saved cursor, then returns the cursor to save."""
while True:
response = requests.get(
f'{BASE_URL}/api/changes',
headers={'x-storyteller-api-key': api_key},
params={'cursor': cursor, 'maxResultCount': 200},
timeout=30
)
if response.status_code == 410:
raise CursorExpired()
response.raise_for_status()
page = response.json()
for item in page['items']:
handle_item(item)
cursor = page['cursor'] # save this durably before reading on
if not page['hasMore']:
return cursor # caught up: wait at least 30 seconds before calling again
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
// The saved cursor has expired: read again from "beginning".
public sealed class CursorExpiredException : Exception { }
public sealed class ContentChangesClient
{
private readonly HttpClient _client;
public ContentChangesClient(string apiKey)
{
_client = new HttpClient { BaseAddress = new Uri("https://integrations.usestoryteller.com/") };
_client.DefaultRequestHeaders.Add("x-storyteller-api-key", apiKey);
}
// Reads every change after the saved cursor, then returns the cursor to save.
public async Task<string> ReadChangesAsync(string cursor, Func<JsonElement, Task> handleItem)
{
while (true)
{
using var response = await _client.GetAsync(
$"api/changes?cursor={Uri.EscapeDataString(cursor)}&maxResultCount=200");
if (response.StatusCode == HttpStatusCode.Gone)
{
throw new CursorExpiredException();
}
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<JsonElement>();
foreach (var item in page.GetProperty("items").EnumerateArray())
{
await handleItem(item);
}
cursor = page.GetProperty("cursor").GetString()!; // save this durably before reading on
if (!page.GetProperty("hasMore").GetBoolean())
{
return cursor; // caught up: wait at least 30 seconds before calling again
}
}
}
}
# Start from the beginning
curl -X GET "https://integrations.usestoryteller.com/api/changes?cursor=beginning&maxResultCount=200" \
-H "x-storyteller-api-key: your-api-key-here"
# Stories and Clips only, from a saved cursor
curl -X GET "https://integrations.usestoryteller.com/api/changes?cursor=AqyzQvJMoRxaAAAAAAAsmlcAAAAAACyaAA&types=story,clip" \
-H "x-storyteller-api-key: your-api-key-here"
Item Types#
type |
What it is | Reading its current state |
|---|---|---|
story |
A Story, including its pages, their media and captions, its Categories and its schedule | GET /api/stories/by-id/{id}/details |
clip |
A Clip, including its media, captions, Categories, Collections and actions | GET /api/clips/by-id/{id}/details |
mediaAsset |
An asset in a Media Library | GET /api/media-assets/by-id/{id} |
play |
A play from a game data feed, which Stories and Clips can show | GET /api/plays/by-id/{id} |
category |
A Category | The Categories endpoints identify Categories by external ID. The Clip list (GET /api/clips) gives a Category's id as categories[].id; the single-Clip endpoints don't |
collection |
A Collection | The Collections list gives a Collection's id as id, and the Clip list (GET /api/clips) as collections[].collectionGuid; the single-Clip endpoints don't |
actionLink |
An action link | The Actions endpoints identify action links by external ID |
Storyteller may add types. Skip an item whose type you don't recognise: your cursor still moves past it.
When a change to a shared item changes what a Story's or Clip's details show, the Story or Clip is recorded too. For example, a Category's external ID changing, or a Category being deleted, records every Story and Clip in it, and a play's details changing records the Clip made from it and the Stories that show it.
Some changes are not recorded per Story or Clip:
- a Story's position or pin in a Category, or in the home order;
- renaming a Category or a Collection, which records the Category or Collection but not the Stories and Clips that show its name;
- a Story leaving one Category while it stays live in another;
- tenant-wide settings, such as the address used to build media URLs and share links.
States and Statuses#
state is the item's effective state when it was last recorded:
state |
Types | Meaning |
|---|---|---|
published |
Stories, Clips | Published and live |
scheduled |
Stories, Clips | Published, with a start time still in the future |
draft |
Stories, Clips | Not published |
expired |
Stories, Clips | Published, but its end has passed |
archived |
Stories, Clips | Archived |
available |
All other types | Exists |
deleted |
All types | Deleted, or no longer exists |
status is the editorial status Storyteller stores for a Story or Clip: Draft, Published, Scheduled, Past or Archived. Most items carry Draft, Published or Archived. status and state can differ: a Story or Clip waiting for its start time usually keeps Published, and so does a published Story that expires. Use state to decide whether an item is live.
A deleted Story or Clip keeps the status it had, unless it no longer exists at all, when status is omitted.
Schedules and Expiry#
Time passing is recorded too. Within about a minute of a start or end time passing, the item appears in the feed again with its new state:
- a scheduled Story or Clip moves from
scheduledtopublishedwhen its start time passes; - a Story moves to
expiredonce it has ended everywhere it is published: every Category it is live in has ended, or its own removal time has passed when it is in none. An expired Story keeps the statusPublished: Storyteller does not change a Story's stored status when it expires; - a Clip whose availability ends is archived, and reads
archivedwith the statusArchived. It can readexpiredwith the statusPublishedbriefly before that.
What You Can Rely On#
- A change is recorded with the save. An item is recorded in the same transaction as the change to it, so every committed change is in the feed (while
registerWritesison). - The cursor never skips a committed change. A change that takes longer to commit than later ones still appears after your cursor.
- A page is safe to read again. Reading never changes anything, so reading again from a cursor you have already used is harmless: you receive the items that changed since, each with its latest state.
- Several changes to one item arrive as one. Between two of your reads, an item appears once, with its latest state.
- A long-running change delays the feed but never makes it skip. While a change is still committing in Storyteller, the feed returns nothing recorded after it; it catches up when that change commits.
The feed does not tell you what changed within an item, and it does not keep history: read the item's current state from its endpoint.
Reading the Feed#
- Start. To load everything, read from
cursor=beginning. To start from now, call without a cursor and save thecursorit returns, or saveheadfrom any response. If you load your existing content another way, saveheadfirst, then load, then read from thathead: anything that changed while you loaded is in the feed. - Process each page, then save its cursor. Handle every item, or queue it durably, before you save the page's
cursor. If you stop part-way, you read the page again from the previous cursor; handling an item twice must be harmless. - Read on while
hasMoreistrue. A page can be empty withhasMoreset when you filter by type (see Filtering by Type). - When
hasMoreisfalse, you have caught up. Wait, then read again from your saved cursor (see Polling). - Fetch the current state. For each item, read its endpoint (see Item Types) and replace your copy. Skip an item whose
versionyou have already handled for thatid. A404 Not Foundfrom a detail read can mean the item has since been deleted, archived or unpublished, or is outside its availability window: treat it as unavailable for now, and a later item follows if it returns.
Cursors belong to your tenant. See Cursor Lifetime for how long a saved cursor stays valid.
Cursor Lifetime#
Storyteller keeps the record of a deleted item in the feed for at least 30 days. If your cursor has not passed a deletion by the time its record is removed, you could miss that deletion, so the feed answers your cursor with 410 Gone and the code cursor_expired (see Error Handling).
- A cursor expires only when a deletion recorded after the position you began reading from was removed before you read it. That position is the feed's newest committed position when you began: a call without a cursor, a
head, or your first read frombeginning.cursor=beginningand a call without a cursor begin afresh, and never expire. Aheadyou save begins at its position, and a savedheadexpires by the same rule once you read from it. - Read through at least every 30 days, reading on while
hasMoreistrue, and in normal running your cursor does not expire. Storyteller keeps records a day longer than it promises, to cover a change that holds the feed back. - A load from
cursor=beginningreads through however many pages it takes. Deletions recorded before its starting position never expire it. A load that takes more than 30 days, or pauses that long, can expire, and so can a load started while a change held the feed back, if a deletion held back with it is removed during the load. - Handle
410whenever it comes, whatever your polling rhythm: a change held back for more than a day can expire a cursor that was read on time.
After a 410, start again from cursor=beginning. The feed then lists every current item, and items deleted in the last 30 days as deleted. When you have read it through, remove from your copy any item it did not list.
Polling#
- Call again at once while
hasMoreistrue. - Once
hasMoreisfalse, wait at least 30 seconds before calling again. - Storyteller monitors feed calls for each API key.
Storyteller plans to add event notifications for these changes. Once they are available, subscribe to them and keep polling the feed only as a catch-up.
Filtering by Type#
types returns only the types you list. Your cursor still moves past the items of other types, so you never read them again with a later filter.
A filtered request examines at most 10,000 recorded changes. When that many pass without enough items of your types, the response returns what it found, possibly no items, with hasMore set to true and a cursor past every change it examined: read on from it.
Versions#
version is an opaque string that changes every time the item is recorded.
- Compare two versions for equality, to tell whether you have already handled this state of the item.
- Compare their order only between two versions of the same item: a later change to an item has a greater version than an earlier one, in ordinal (character-by-character) order.
- Don't parse a version, compute with it, compare versions of different items, or read meaning into the gaps between versions. Their form may change.
Cursors are opaque too: pass them back exactly as returned. The feed checks a cursor's format, your tenant and its range; it does not sign cursors, so a cursor changed by hand is read from wherever it now points, in your own tenant.
Register Status#
Every response carries status, which says whether the feed is complete for your tenant.
registerWrites is on in normal operation. Storyteller can switch recording changes off to protect the service:
- While it is
off, changes are still saved and your reads of items still return their current state, but changes are not recorded: the feed does not show them. - After it is switched back
on, Storyteller records every item again. Every item then appears in the feed after your existing cursor, so your client fetches each one again and catches up with everything that changed meanwhile. You do not need to reset your cursor. registerWritesChangedAttells you when it last changed. If you seeoff, treat your copy as possibly out of date until it is backonand you have read the feed through.
repairPending is true while a recent save's changes could not be recorded at once and are waiting to be recorded, which usually takes about a minute. Those items then appear in the feed in the ordinary way; you don't need to do anything.
Error Handling#
All errors are RFC 7807 Problem Details.
400 Bad Request for a query parameter the feed does not accept, or a cursor that is malformed, was issued for another tenant, or is ahead of the feed:
{
"status": 400,
"type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
"title": "Bad Request",
"detail": "Use an optional cursor returned by this endpoint (or beginning) and maxResultCount from 1 to 200.",
"instance": ""
}
400 Bad Request for an unknown or empty type:
{
"status": 400,
"type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
"title": "Bad Request",
"detail": "types must be a comma-separated list of story, clip, mediaAsset, play, category, collection, actionLink.",
"instance": ""
}
410 Gone for a cursor older than the feed keeps (see Cursor Lifetime). Read again from cursor=beginning:
{
"status": 410,
"type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.9",
"title": "Gone",
"detail": "The cursor is older than the feed keeps. Read again from cursor=beginning.",
"instance": "",
"code": "cursor_expired"
}
401 Unauthorized without a valid Server App key:
{
"status": 401,
"type": "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1",
"title": "Unauthorized",
"detail": "No valid API key provided",
"instance": ""
}
Related Documentation#
- Stories API – Story details by
id - Clips API – Clip details by
id - Collections – Collections and their
collectionGuid - Authentication – Required header reference