Clips API#
The Clips endpoints expose the short-form clips that are available in your tenant. Integrations use this data to populate media pickers, drive recommendation rails, or deep link back into Storyteller CMS analytics.
A Server App can also add an existing Category to a Clip, or remove one, while preserving the Clip's other Category memberships, media and publication state.
Endpoint Summary#
| Endpoint | Description |
|---|---|
GET /api/clips |
Retrieve currently available clips, or filter by management status, with pagination, search, and sorting |
GET /api/clips/{externalId} |
Fetch a single clip by its external identifier |
GET /api/clips/by-id/{id} |
Fetch a single clip by its internal GUID identifier |
GET /api/clips/{externalId}/details |
Retrieve comprehensive clip details including caption localisations |
GET /api/clips/by-id/{id}/details |
Retrieve comprehensive clip details by internal GUID identifier |
PUT /api/clips/{clipExternalId}/categories/{categoryExternalId} |
Add an existing Category to a Clip identified by external ID, preserving other memberships |
PUT /api/clips/by-id/{clipId}/categories/{categoryExternalId} |
Add a Category using the Clip's internal GUID when needed |
DELETE /api/clips/{clipExternalId}/categories/{categoryExternalId} |
Remove one Category from a Clip identified by external ID, preserving other memberships |
DELETE /api/clips/by-id/{clipId}/categories/{categoryExternalId} |
Remove a Category using the Clip's internal GUID when needed |
ℹ️ Clip analytics are documented separately in Clip Analytics.
Availability and Status#
Clip responses distinguish the editorial status stored by Storyteller from the effective status at the time of the request:
statusis the editorial state:Draft,Published, orArchived;effectiveStatusisDraft,Published,Scheduled, orArchived; andavailableFromandavailableUntildescribe the optional availability window.
Scheduled Clips retain an editorial status of Published. A window is half-open: a Clip is available at exactly availableFrom and unavailable at exactly availableUntil. At the end of a window, its effective status becomes Archived immediately; Storyteller then persists the archive and clears the window. An omitted availableUntil means that the Clip remains available until it is changed manually.
Draft and Archived Clips have both availability fields set to null. A directly published Clip has a null availableFrom and may have a future availableUntil.
List Clips (GET /api/clips)#
Query Parameters#
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
searchText |
string | No | – | Case-insensitive match against displayTitle |
externalId |
string | No | – | Exact match on externalId |
publishStatus |
string | No | – | Management filter. Use All, or one or more of Published, Scheduled, Draft, and Archived separated by commas (for example Draft,Scheduled). Values are case-insensitive. Archived includes both manually archived Clips and Clips whose availability window has ended. |
publishedSince |
string (ISO 8601) | No | – | Return clips published on or after this date/time. Accepts ISO 8601 format (e.g., 2025-01-15T00:00:00Z) or standard date formats. |
updatedSince |
string (ISO 8601) | No | – | Return Clips whose updatedAt is at or after this time. Must include Z or a UTC offset, for example 2026-10-15T09:30:00Z; URL-encode + as %2B. Up to seven fractional-second digits are kept. Any other value returns 400 Bad Request. Without sort, or with a value that isn't one of the supported sorts, results come newest first (LastModifiedDesc). See Reconciling Changes. |
afterId |
string (GUID) | No | – | The change read's cursor: with updatedSince and sort=LastModifiedAsc, return only the Clips after this one, that is Clips with a later updatedAt, or the same updatedAt and a later id in the server's order. Pass back continuation.nextAfterId, with currentPage=1. Any other combination, an offset (currentPage above 1 or skipCount), or a value that isn't a GUID, returns 400 Bad Request. |
currentPage |
integer | No | 1 |
Must be >= 1 |
pageSize |
integer | No | 10 |
Must be between 1 and 1000 |
skipCount |
integer | No | 0 |
Legacy offset-style pagination alias. When used directly on the integrations endpoint it must align to the effective page size. |
maxResultCount |
integer | No | 10 |
Legacy offset-style page-size alias. Maximum 1000. |
sort |
string | No | AlphabeticalAsc (LastModifiedDesc with updatedSince) |
Supported values: AlphabeticalAsc, CreatedDesc, PublishedDesc, LastModifiedDesc (most recent updatedAt first) and LastModifiedAsc (oldest updatedAt first, then by id). Matching ignores case, spaces, hyphens and underscores, so last-modified-desc works too. |
Without
publishStatus, the endpoint returns only Clips that are currently viewer-facing. ExplicitPublishedhas the same available-only meaning. UseAll,Scheduled,Draft,Archived, or a comma-separated list of them for management reconciliation; those responses can include Clips that are not viewer-facing.To fetch one Clip whatever its state, combine
externalIdwithpublishStatus=All. Deleted Clips are never returned.You can page this endpoint using either
currentPage+pageSizeor legacy-styleskipCount+maxResultCount.In legacy mode, omitted aliases fall back to
skipCount=0andmaxResultCount=10. Because integrations responses still returncurrentPage,skipCountmust land on a page boundary for the effective page size.
Example Response#
{
"clips": [
{
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "clip-001",
"internalTitle": "Product Launch Teaser",
"displayTitle": "Product Launch Teaser",
"createdAt": "2025-01-15T10:30:00Z",
"publishedAt": "2025-01-15T12:00:00Z",
"updatedAt": "2025-01-16T08:15:00Z",
"status": "Published",
"effectiveStatus": "Published",
"availableFrom": "2025-01-15T12:00:00Z",
"availableUntil": "2025-01-20T12:00:00Z",
"duration": 32000,
"hasAudio": true,
"videoUrl": "https://cdn.yourtenant.net/clips/clip-001/video.mp4",
"thumbnailUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail.jpg",
"thumbnailMediumUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail-medium.jpg",
"thumbnailLargeUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail-large.jpg",
"thumbnailWebpUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail.webp",
"playcardUrl": "https://cdn.yourtenant.net/clips/clip-001/playcard.jpg",
"cmsUrl": "https://yourtenant.usestoryteller.com/clips/11111111-1111-4111-8111-111111111111/form",
"cmsAnalyticsUrl": "https://yourtenant.usestoryteller.com/clips/11111111-1111-4111-8111-111111111111/analytics",
"universalLinkUrl": "https://yourtenant.shar.estori.es/clips/11111111-1111-4111-8111-111111111111",
"deepLinkUrl": "yourtenantstories://open/clip/11111111-1111-4111-8111-111111111111",
"captionRemoteUrl": "https://cdn.yourtenant.net/clips/clip-001/captions.vtt",
"categories": [
{
"id": "22222222-2222-4222-8222-222222222222",
"title": "Product",
"externalId": "product",
"type": "Other",
"placement": "home"
}
],
"collections": [
{
"id": "featured-clips",
"collectionGuid": "33333333-3333-4333-8333-333333333333",
"internalTitle": "Featured Clips",
"displayTitle": "Featured Clips"
}
]
}
],
"pageSize": 10,
"currentPage": 1,
"totalPages": 5,
"totalCount": 42
}
Response Fields#
| Field | Type | Description |
|---|---|---|
id |
string (GUID) | Internal clip identifier |
externalId |
string | External system identifier |
internalTitle |
string | Internal title shown in the CMS |
displayTitle |
string | Consumer-facing title |
createdAt |
string (ISO 8601) | Clip creation time |
publishedAt |
string (ISO 8601) | Most recent publish timestamp (falls back to creation time) |
updatedAt |
string (ISO 8601) | When something in the Clip's response last changed, in UTC with Z and up to seven fractional-second digits. It is never earlier than publishedAt, so updatedSince returns every Clip that publishedSince returns for the same time. The one exception is a Clip given a future creation or publication time, which keeps the time it was saved. See Reconciling Changes. |
status |
string | Editorial state: Draft, Published, or Archived |
effectiveStatus |
string | Time-aware state: Draft, Published, Scheduled, or Archived |
availableFrom |
string (ISO 8601) or null | Inclusive start of the availability window |
availableUntil |
string (ISO 8601) or null | Exclusive end of the availability window; null means no automatic expiry |
duration |
integer | Clip duration in milliseconds |
hasAudio |
boolean | Indicates whether the clip has audio |
videoUrl |
string | CDN-aware video URL for the clip |
thumbnailUrl |
string | Primary thumbnail |
thumbnailMediumUrl |
string | Medium thumbnail (falls back to primary) |
thumbnailLargeUrl |
string | Large thumbnail (falls back to primary) |
thumbnailWebpUrl |
string | WebP thumbnail if available |
playcardUrl |
string | Playcard image/video used in some clients |
cmsUrl |
string | Direct link to the clip’s CMS form |
cmsAnalyticsUrl |
string | Link to the clip analytics dashboard inside the CMS |
universalLinkUrl |
string | Viewer-friendly link using the tenant’s sharing domain |
deepLinkUrl |
string | Custom URL scheme deep link |
captionRemoteUrl |
string | CDN-formatted caption file if captions exist |
categories |
array | Assigned categories (see table below) |
collections |
array | Collections containing the clip |
An ascending change read (sort=LastModifiedAsc with updatedSince) also returns continuation, where the next page starts. Other requests don't include it.
"continuation": {
"hasMore": true,
"nextUpdatedSince": "2026-10-15T09:30:00.1234567Z",
"nextAfterId": "11111111-1111-4111-8111-111111111111"
}
| Field | Type | Description |
|---|---|---|
continuation.hasMore |
boolean | Whether more Clips follow this page |
continuation.nextUpdatedSince |
string (ISO 8601) | Pass as updatedSince for the next page: the updatedAt of the last Clip this page selected, when it was selected. On an empty page, your own updatedSince, so the continuation is always a valid checkpoint. |
continuation.nextAfterId |
string (GUID) or null | Pass as afterId for the next page: the id of that Clip. On an empty page, your own afterId, or null if you sent none. |
Category Object
| Field | Description |
|---|---|
id |
Internal category GUID |
title |
Category display name |
externalId |
Category external identifier |
type |
Category type (Other, Editorial, Game, Team, Priority, Player) |
placement |
Category placement code when one is assigned |
Collection Object
| Field | Description |
|---|---|
id |
Collection internal identifier |
collectionGuid |
Internal collection GUID |
internalTitle |
CMS title |
displayTitle |
Public-facing title |
Pagination Object
| Field | Description |
|---|---|
pageSize |
Number of items returned per page |
currentPage |
Current page number |
totalPages |
Total available pages |
totalCount |
Exact total number of matching clips |
Usage Examples#
import fetch from 'node-fetch';
export async function listClips(apiKey, { searchText, publishedSince, pageSize = 20, page = 1 } = {}) {
const params = new URLSearchParams({ currentPage: page, pageSize });
if (searchText) params.set('searchText', searchText);
if (publishedSince) params.set('publishedSince', publishedSince);
const response = await fetch(`https://integrations.usestoryteller.com/api/clips?${params}`, {
headers: { 'x-storyteller-api-key': apiKey }
});
if (!response.ok) {
const problem = await response.json();
throw new Error(`${problem.title}: ${problem.detail}`);
}
return response.json();
}
import requests
from datetime import datetime
def list_clips(
api_key: str,
search_text: str | None = None,
published_since: datetime | str | None = None,
page: int = 1,
page_size: int = 20
):
params = {
'currentPage': page,
'pageSize': page_size
}
if search_text:
params['searchText'] = search_text
if published_since:
# Convert datetime to ISO 8601 string if needed
if isinstance(published_since, datetime):
params['publishedSince'] = published_since.isoformat()
else:
params['publishedSince'] = published_since
response = requests.get(
'https://integrations.usestoryteller.com/api/clips',
headers={'x-storyteller-api-key': api_key},
params=params,
timeout=30
)
response.raise_for_status()
return response.json()
public sealed class ClipClient
{
private readonly HttpClient _client;
public ClipClient(string apiKey)
{
_client = new HttpClient { BaseAddress = new Uri("https://integrations.usestoryteller.com/") };
_client.DefaultRequestHeaders.Add("x-storyteller-api-key", apiKey);
}
public async Task<ListClipsResponse?> GetClipsAsync(
int page = 1,
int pageSize = 20,
string? searchText = null,
DateTime? publishedSince = null)
{
var query = new Dictionary<string, string>
{
["currentPage"] = page.ToString(),
["pageSize"] = pageSize.ToString()
};
if (!string.IsNullOrWhiteSpace(searchText)) query["searchText"] = searchText;
if (publishedSince.HasValue) query["publishedSince"] = publishedSince.Value.ToString("o");
var url = QueryHelpers.AddQueryString("api/clips", query);
return await _client.GetFromJsonAsync<ListClipsResponse>(url);
}
}
# Get clips published since January 15, 2025
curl -X GET "https://integrations.usestoryteller.com/api/clips?currentPage=1&pageSize=20&publishedSince=2025-01-15T00:00:00Z" \
-H "x-storyteller-api-key: YOUR_API_KEY"
# Reconcile clips that have not reached their availability start
curl -X GET "https://integrations.usestoryteller.com/api/clips?publishStatus=Scheduled&pageSize=100" \
-H "x-storyteller-api-key: YOUR_API_KEY"
# Fetch one clip by external ID, whatever its state
curl -X GET "https://integrations.usestoryteller.com/api/clips?externalId=example-clip-42&publishStatus=All" \
-H "x-storyteller-api-key: YOUR_API_KEY"
Reconciling Changes#
Every Clip response carries updatedAt, the time something in that Clip's response last changed. Use it with updatedSince to fetch only the Clips that changed since your last run, for example as a scheduled safety net alongside Change Notification Webhooks, whose delivery is best effort.
What moves updatedAt#
Any change to the Clip itself that its response shows, including:
- titles, long description and external ID;
- status and availability, including a scheduled start or end, which moves
updatedAtwithin about a minute of the boundary; - Categories added or removed, and Collection membership;
- the primary and secondary actions;
- the video, thumbnails, captions (every caption upload) and share link.
Some changes are not reflected per Clip and do not move updatedAt:
- renaming a Category, Collection or Action, or changing which Categories feed a Collection;
- deleting a Category, Collection or Action that Clips use;
- tenant-wide settings, such as the CDN address used to build media URLs.
Tenant-wide maintenance, such as regenerating every Clip's share link, can move updatedAt for many Clips at once. A run after that simply returns a larger window. Deleted Clips are never returned, so updatedSince does not report deletions.
Recipe#
Read changes oldest first, one page after another, from the cursor in each response's continuation.
-
Start from your cutoff. Request the first page with
updatedSinceset to the earliest change you need,sort=LastModifiedAscandcurrentPage=1. Every Clip has anupdatedAtno earlier than itspublishedAt, so a cutoff before your oldest Clip reads them all, and a cutoff T reads every Clip published since T.GET /api/clips?updatedSince=2026-10-15T09:25:00Z&publishStatus=All&sort=LastModifiedAsc&pageSize=100¤tPage=1 -
Follow the continuation. Upsert each Clip by
id. Whilecontinuation.hasMoreistrue, request the next page withupdatedSinceset tocontinuation.nextUpdatedSince,afterIdset tocontinuation.nextAfterId, andcurrentPage=1again. Pass both values back exactly as returned; the cursor compares ids in the server's order, which is not the order of their text.GET /api/clips?updatedSince=2026-10-15T09:30:00.1234567Z&afterId=11111111-1111-4111-8111-111111111111&publishStatus=All&sort=LastModifiedAsc&pageSize=100¤tPage=1 -
Save your place after each page. Keep the last
continuationas your checkpoint, so an interrupted run resumes from its last page. - Run again on a schedule. Start each run from the checkpoint's
nextUpdatedSinceminus a few minutes (we suggest 5), withoutafterId. Changes are stamped when they are saved and can become visible a little later; the overlap catches them and absorbs small clock differences. - Expect repeats. The overlap returns some Clips again, and a Clip that changes while you walk moves to the end and is read again later. Nothing is skipped. Keep the version with the latest
updatedAt.
Take the cursor from continuation, not from the last Clip on the page. A Clip can change or be deleted between choosing the page and returning it, so the page may hold fewer Clips, or a newer updatedAt, than its position in the walk.
The changes listed above that don't move updatedAt are only picked up by reading the Clips again from an early cutoff.
Always pass publishStatus=All: without it, the list returns only Clips that are currently viewer-facing, so a Clip that was unpublished or archived would drop out of your results instead of arriving as a change.
Get Clip by External ID (GET /api/clips/{externalId})#
Returns the lightweight single-clip payload shown below. Use this when you already know the clip external ID and need CMS or sharing links for that clip.
Unlike GET /api/clips, these single-item endpoints do not currently include the list-only metadata compatibility fields such as categories[].id, categories[].placement, or collections[].collectionGuid.
Single-item endpoints are viewer-facing reads: a scheduled Clip returns 404 Not Found before availableFrom, and an ended Clip returns 404 Not Found at and after availableUntil. To read a Clip that may be unavailable, use the list endpoint with externalId and publishStatus=All.
Example#
curl -X GET "https://integrations.usestoryteller.com/api/clips/clip-001" \
-H "x-storyteller-api-key: YOUR_API_KEY"
Get Clip by ID (GET /api/clips/by-id/{id})#
Fetches a single clip using its internal GUID identifier. This is useful when you have the clip's internal ID from another Storyteller API response (such as workflow results or analytics data) and need to retrieve the clip metadata.
Path Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string (GUID) | Yes | The internal clip identifier |
Example Response#
Returns the lightweight single-clip payload:
{
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "clip-001",
"internalTitle": "Product Launch Teaser",
"displayTitle": "Product Launch Teaser",
"createdAt": "2025-01-15T10:30:00Z",
"publishedAt": "2025-01-15T12:00:00Z",
"updatedAt": "2025-01-16T08:15:00Z",
"status": "Published",
"effectiveStatus": "Published",
"availableFrom": "2025-01-15T12:00:00Z",
"availableUntil": "2025-01-20T12:00:00Z",
"duration": 32000,
"hasAudio": true,
"videoUrl": "https://cdn.yourtenant.net/clips/clip-001/video.mp4",
"thumbnailUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail.jpg",
"thumbnailMediumUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail-medium.jpg",
"thumbnailLargeUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail-large.jpg",
"thumbnailWebpUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail.webp",
"playcardUrl": "https://cdn.yourtenant.net/clips/clip-001/playcard.jpg",
"cmsUrl": "https://yourtenant.usestoryteller.com/clips/11111111-1111-4111-8111-111111111111/form",
"cmsAnalyticsUrl": "https://yourtenant.usestoryteller.com/clips/11111111-1111-4111-8111-111111111111/analytics",
"universalLinkUrl": "https://yourtenant.shar.estori.es/clips/11111111-1111-4111-8111-111111111111",
"deepLinkUrl": "yourtenantstories://open/clip/11111111-1111-4111-8111-111111111111",
"captionRemoteUrl": "https://cdn.yourtenant.net/clips/clip-001/captions.vtt",
"categories": [
{
"title": "Product",
"externalId": "product",
"type": "Other"
}
],
"collections": [
{
"id": "featured-clips",
"internalTitle": "Featured Clips",
"displayTitle": "Featured Clips"
}
]
}
Usage Examples#
import fetch from 'node-fetch';
export async function getClipById(apiKey, clipId) {
const response = await fetch(`https://integrations.usestoryteller.com/api/clips/by-id/${clipId}`, {
headers: { 'x-storyteller-api-key': apiKey }
});
if (!response.ok) {
throw await response.json();
}
return response.json();
}
import requests
def get_clip_by_id(api_key: str, clip_id: str) -> dict:
response = requests.get(
f"https://integrations.usestoryteller.com/api/clips/by-id/{clip_id}",
headers={'x-storyteller-api-key': api_key},
timeout=30
)
response.raise_for_status()
return response.json()
public async Task<ClipResponse?> GetClipByIdAsync(string apiKey, Guid clipId)
{
using var client = new HttpClient
{
BaseAddress = new Uri("https://integrations.usestoryteller.com/")
};
client.DefaultRequestHeaders.Add("x-storyteller-api-key", apiKey);
var response = await client.GetAsync($"api/clips/by-id/{clipId}");
if (!response.IsSuccessStatusCode)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
throw new InvalidOperationException(problem?.Detail ?? "Unable to fetch clip");
}
return await response.Content.ReadFromJsonAsync<ClipResponse>();
}
curl -X GET "https://integrations.usestoryteller.com/api/clips/by-id/11111111-1111-4111-8111-111111111111" \
-H "x-storyteller-api-key: YOUR_API_KEY"
Clip Details by External ID (GET /api/clips/{externalId}/details)#
Retrieve comprehensive clip details including caption localisations. This endpoint extends the basic clip response with additional caption localisation information, providing all language-specific caption URLs.
Path Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
externalId |
string | Yes | The external clip identifier |
Example Response#
{
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "clip-001",
"internalTitle": "Product Launch Teaser",
"displayTitle": "Product Launch Teaser",
"createdAt": "2025-01-15T10:30:00Z",
"publishedAt": "2025-01-15T12:00:00Z",
"updatedAt": "2025-01-16T08:15:00Z",
"status": "Published",
"effectiveStatus": "Published",
"availableFrom": "2025-01-15T12:00:00Z",
"availableUntil": "2025-01-20T12:00:00Z",
"duration": 32000,
"hasAudio": true,
"videoUrl": "https://cdn.yourtenant.net/clips/clip-001/video.mp4",
"thumbnailUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail.jpg",
"thumbnailMediumUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail-medium.jpg",
"thumbnailLargeUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail-large.jpg",
"thumbnailWebpUrl": "https://cdn.yourtenant.net/clips/clip-001/thumbnail.webp",
"playcardUrl": "https://cdn.yourtenant.net/clips/clip-001/playcard.jpg",
"cmsUrl": "https://yourtenant.usestoryteller.com/clips/11111111-1111-4111-8111-111111111111/form",
"cmsAnalyticsUrl": "https://yourtenant.usestoryteller.com/clips/11111111-1111-4111-8111-111111111111/analytics",
"universalLinkUrl": "https://yourtenant.shar.estori.es/clips/11111111-1111-4111-8111-111111111111",
"deepLinkUrl": "yourtenantstories://open/clip/11111111-1111-4111-8111-111111111111",
"caption": {
"remoteUrl": "https://cdn.yourtenant.net/clips/clip-001/captions.vtt",
"localisations": [
{
"languageCode": "es",
"remoteUrl": "https://cdn.yourtenant.net/clips/clip-001/captions.es.vtt"
},
{
"languageCode": "fr",
"remoteUrl": "https://cdn.yourtenant.net/clips/clip-001/captions.fr.vtt"
}
]
},
"categories": [
{
"title": "Product",
"externalId": "product",
"type": "Other"
}
],
"collections": [
{
"id": "featured-clips",
"internalTitle": "Featured Clips",
"displayTitle": "Featured Clips"
}
]
}
Additional Response Fields#
The details endpoint includes these additional fields beyond the basic clip response:
| Field | Type | Description |
|---|---|---|
caption |
object | Caption details including localisations |
caption.remoteUrl |
string | Primary caption file URL (usually WebVTT) |
caption.localisations |
array | Language-specific caption files |
caption.localisations[].languageCode |
string | ISO language code (e.g., "es", "fr") |
caption.localisations[].remoteUrl |
string | CDN-formatted caption URL for the language |
Usage Examples#
import fetch from 'node-fetch';
export async function getClipDetails(apiKey, externalId) {
const response = await fetch(`https://integrations.usestoryteller.com/api/clips/${externalId}/details`, {
headers: { 'x-storyteller-api-key': apiKey }
});
if (!response.ok) {
throw await response.json();
}
return response.json();
}
import requests
def get_clip_details(api_key: str, external_id: str) -> dict:
response = requests.get(
f"https://integrations.usestoryteller.com/api/clips/{external_id}/details",
headers={'x-storyteller-api-key': api_key},
timeout=30
)
response.raise_for_status()
return response.json()
public async Task<ClipDetails?> GetClipDetailsAsync(string apiKey, string externalId)
{
using var client = new HttpClient
{
BaseAddress = new Uri("https://integrations.usestoryteller.com/")
};
client.DefaultRequestHeaders.Add("x-storyteller-api-key", apiKey);
var response = await client.GetAsync($"api/clips/{externalId}/details");
if (!response.IsSuccessStatusCode)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
throw new InvalidOperationException(problem?.Detail ?? "Unable to fetch clip");
}
return await response.Content.ReadFromJsonAsync<ClipDetails>();
}
curl -X GET "https://integrations.usestoryteller.com/api/clips/clip-001/details" \
-H "x-storyteller-api-key: YOUR_API_KEY"
Clip Details by ID (GET /api/clips/by-id/{id}/details)#
Retrieve comprehensive clip details using its internal GUID identifier. This endpoint provides the same detailed information as the external ID version, including caption localisations.
Path Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string (GUID) | Yes | The internal clip identifier |
Example Response#
Returns the same structure as the external ID details endpoint. See the example response in the previous section.
Usage Examples#
import fetch from 'node-fetch';
export async function getClipDetailsById(apiKey, clipId) {
const response = await fetch(`https://integrations.usestoryteller.com/api/clips/by-id/${clipId}/details`, {
headers: { 'x-storyteller-api-key': apiKey }
});
if (!response.ok) {
throw await response.json();
}
return response.json();
}
import requests
def get_clip_details_by_id(api_key: str, clip_id: str) -> dict:
response = requests.get(
f"https://integrations.usestoryteller.com/api/clips/by-id/{clip_id}/details",
headers={'x-storyteller-api-key': api_key},
timeout=30
)
response.raise_for_status()
return response.json()
public async Task<ClipDetails?> GetClipDetailsByIdAsync(string apiKey, Guid clipId)
{
using var client = new HttpClient
{
BaseAddress = new Uri("https://integrations.usestoryteller.com/")
};
client.DefaultRequestHeaders.Add("x-storyteller-api-key", apiKey);
var response = await client.GetAsync($"api/clips/by-id/{clipId}/details");
if (!response.IsSuccessStatusCode)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
throw new InvalidOperationException(problem?.Detail ?? "Unable to fetch clip");
}
return await response.Content.ReadFromJsonAsync<ClipDetails>();
}
curl -X GET "https://integrations.usestoryteller.com/api/clips/by-id/11111111-1111-4111-8111-111111111111/details" \
-H "x-storyteller-api-key: YOUR_API_KEY"
Add a Category to a Clip (PUT /api/clips/{clipExternalId}/categories/{categoryExternalId})#
Use this endpoint from a secure backend with a Server App API key in the x-storyteller-api-key header. The key determines the tenant; both the Clip and Category must already exist in that tenant and must not be deleted. The request does not create a Category.
Category assignment works for Draft, Scheduled, Published and Archived Clips, including Clips whose media is not yet processed. It does not publish the Clip, change its availability window or reprocess media. Other memberships are retained, and Storyteller applies its existing automatic Category selection and Collection rules.
Path Parameters#
| Parameter | Type | Description |
|---|---|---|
clipExternalId |
string | The existing Clip's external identifier, at most 450 characters, with no surrounding whitespace. It is not normalized. |
categoryExternalId |
string | The existing Category's canonical external ID, at most 256 characters. Use lowercase and URL-safe separators such as hyphens. Whitespace, uppercase and reserved characters are rejected rather than normalized. |
The external ID must identify one active Clip in the authenticated tenant. No match returns 404 Not Found; multiple matches return 409 Conflict with code clip_external_id_ambiguous, without changing either Clip. No preliminary lookup is required for a unique external ID.
If you know the intended Clip's internal GUID, the equivalent fallback is PUT /api/clips/by-id/{clipId}/categories/{categoryExternalId} with the same {} body and response. clipId must be a nonempty GUID. This route can disambiguate Clips that share an external ID.
For readback, use GET /api/clips/{externalId}/state for a unique external ID, or GET /api/clips/by-id/{id}/state using the response's internal id. These state reads include Draft and Archived Clips and do not apply the availability filtering of the viewer-facing Clip lookups above.
Use the Categories API to look up the Category's externalId. The Category identifier in the route is an external ID; the categoryIds in the response below are internal GUIDs.
Request Body#
Send Content-Type: application/json and an empty JSON object. A missing body, null, another JSON type or any additional property is rejected.
{}
Response (200 OK)#
Both a new association and an already-associated Category return the current Clip state:
{
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "example-clip",
"internalTitle": "Example Clip",
"displayTitle": null,
"status": "Draft",
"effectiveStatus": "Draft",
"availableFrom": null,
"availableUntil": null,
"publishedAt": null,
"updatedAt": "2026-09-29T12:00:00Z",
"categoryIds": [
"22222222-2222-4222-8222-222222222222",
"33333333-3333-4333-8333-333333333333"
],
"hasProcessed": false,
"evaluatedAt": "2026-09-29T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
id |
GUID | Internal Clip identifier. |
externalId |
string or null | Clip external identifier. |
internalTitle |
string or null | Internal title. |
displayTitle |
string or null | Display title. |
status |
string | Stored editorial state: Draft, Published or Archived. |
effectiveStatus |
string | State at evaluation time: Draft, Published, Scheduled or Archived. |
availableFrom |
timestamp or null | Availability start, in UTC. |
availableUntil |
timestamp or null | Availability end, in UTC. |
publishedAt |
timestamp or null | First-publication time, in UTC. |
updatedAt |
timestamp | When something in the Clip's response last changed, in UTC. Adding or removing a Category moves it. |
categoryIds |
GUID array | Current Category memberships, including the requested Category. |
hasProcessed |
boolean | Whether the Clip has processed media. |
evaluatedAt |
timestamp | UTC time used to evaluate the response. |
Example Requests#
These examples use a local development URL. Replace it with your Integrations API base URL and keep the Server App key on your backend.
const apiKey = 'YOUR_SERVER_APP_API_KEY';
const clipExternalId = 'example-clip';
const categoryExternalId = 'example-category';
const response = await fetch(
`http://localhost:7071/api/clips/${encodeURIComponent(clipExternalId)}/categories/${encodeURIComponent(categoryExternalId)}`,
{
method: 'PUT',
headers: {
'x-storyteller-api-key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({})
}
);
if (!response.ok) throw new Error(await response.text());
const clip = await response.json();
from urllib.parse import quote
import requests
api_key = 'YOUR_SERVER_APP_API_KEY'
clip_external_id = 'example-clip'
category_external_id = 'example-category'
response = requests.put(
f'http://localhost:7071/api/clips/{quote(clip_external_id, safe="")}/categories/{quote(category_external_id, safe="")}',
headers={'x-storyteller-api-key': api_key},
json={},
timeout=30
)
response.raise_for_status()
clip = response.json()
using System.Net.Http;
using System.Text;
using System.Text.Json;
using var client = new HttpClient();
var clipExternalId = "example-clip";
var categoryExternalId = "example-category";
using var request = new HttpRequestMessage(HttpMethod.Put,
$"http://localhost:7071/api/clips/{Uri.EscapeDataString(clipExternalId)}/categories/{Uri.EscapeDataString(categoryExternalId)}");
request.Headers.Add("x-storyteller-api-key", "YOUR_SERVER_APP_API_KEY");
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
using var clip = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
curl --request PUT \
'http://localhost:7071/api/clips/example-clip/categories/example-category' \
--header 'x-storyteller-api-key: YOUR_SERVER_APP_API_KEY' \
--header 'Content-Type: application/json' \
--data '{}'
Retries and Propagation#
Repeating the same PUT succeeds without adding another membership, resetting its association age or moving an existing manual Collection entry. A newly added Clip follows the existing Collection insertion rules, including insertion at the front of a matching manual Collection.
A successful response confirms the membership is committed and refresh work has been accepted. Search, Collection caches and other derived views update asynchronously. If the Category has an existing algorithm boost, its contribution depends on that configuration and normal search propagation; this endpoint does not configure a boost or guarantee a ranking position.
Because a repeated PUT keeps the existing association, it does not restart a boost that decays over time. To re-boost a Clip, remove the Category and then add it again.
For 409 Conflict with clip_state_conflict, read the current Clip state before retrying the same PUT. If the code is clip_external_id_ambiguous, identify the intended Clip and use its GUID fallback; repeating the ambiguous external-ID request does not select a Clip. A 500 Internal Server Error or a lost response can occur after the membership has committed: read back the state, then retry the same PUT if needed to request reconciliation. Retries do not guarantee exactly-once webhook delivery.
| Status | Meaning |
|---|---|
400 Bad Request |
Invalid request body, blank/overlong Clip external ID or surrounding whitespace, empty fallback Clip GUID, or non-canonical Category external ID. |
401 Unauthorized |
Missing or invalid Server App API key. |
404 Not Found |
The Clip or Category is unavailable in the authenticated tenant. The detail is Clip or Category not found. |
409 Conflict |
Multiple Clips match the external ID (clip_external_id_ambiguous), or the Clip changed concurrently (clip_state_conflict). Use the recovery described above. |
500 Internal Server Error |
The request failed, potentially after commit. Read back before retrying. |
Remove a Category from a Clip (DELETE /api/clips/{clipExternalId}/categories/{categoryExternalId})#
Use this endpoint from a secure backend with a Server App API key in the x-storyteller-api-key header. It removes one Category from one Clip in the key's tenant. The Clip's other Categories, media, publication state and availability window are unchanged.
Removal works for Draft, Scheduled, Published and Archived Clips, including Clips whose media is not yet processed. You can remove a Clip's last Category.
Not the same as deleting a Category
This endpoint removes the Category from one Clip. DELETE /api/categories/{externalId} in the Categories API deletes the Category itself and removes it from every Story and Clip.
Path Parameters#
The path parameters follow the same rules as for adding a Category:
| Parameter | Type | Description |
|---|---|---|
clipExternalId |
string | The existing Clip's external identifier, at most 450 characters, with no surrounding whitespace. It is not normalized. |
categoryExternalId |
string | The Category's canonical external ID, at most 256 characters, lowercase with URL-safe separators. Non-canonical values are rejected rather than normalized. |
The external ID must identify one active Clip in the authenticated tenant. No match returns 404 Not Found; multiple matches return 409 Conflict with code clip_external_id_ambiguous, without changing either Clip.
If you know the intended Clip's internal GUID, the equivalent fallback is DELETE /api/clips/by-id/{clipId}/categories/{categoryExternalId}. clipId must be a nonempty GUID.
Request Body#
Send no request body. A body is ignored.
Response (200 OK)#
The response is the current Clip state, with the same fields as adding a Category. categoryIds no longer includes the removed Category:
{
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "example-clip",
"internalTitle": "Example Clip",
"displayTitle": null,
"status": "Published",
"effectiveStatus": "Published",
"availableFrom": null,
"availableUntil": null,
"publishedAt": "2026-09-29T12:00:00Z",
"updatedAt": "2026-10-05T12:00:00Z",
"categoryIds": [
"22222222-2222-4222-8222-222222222222"
],
"hasProcessed": true,
"evaluatedAt": "2026-10-05T12:00:00Z"
}
A Category that exists but is not on the Clip also returns 200 OK with the current state, so a repeated request is safe. 404 Not Found means the Clip or the Category does not exist in the tenant.
What Removal Changes#
- Collections. The Clip leaves any Collection that it belongs to only through this Category. If another of the Clip's Categories also feeds that Collection, the Clip stays. Storyteller does not record whether a Collection membership was added by hand, so a Clip that was also added to that Collection manually leaves it too.
- Displayed Category. If the removed Category was the Category shown on the Clip, Storyteller selects one again using its usual rules.
- Algorithm boosts. Once search updates, the Clip no longer receives any boost configured for the Category.
- Adding the Category back. This creates a new association, so a boost that decays over time starts again from full strength. The Clip is also inserted at the front of matching manual Collections. Removing and then re-adding a Category is how you re-boost a Clip.
Example Requests#
These examples use a local development URL. Replace it with your Integrations API base URL and keep the Server App key on your backend.
const apiKey = 'YOUR_SERVER_APP_API_KEY';
const clipExternalId = 'example-clip';
const categoryExternalId = 'example-category';
const response = await fetch(
`http://localhost:7071/api/clips/${encodeURIComponent(clipExternalId)}/categories/${encodeURIComponent(categoryExternalId)}`,
{
method: 'DELETE',
headers: { 'x-storyteller-api-key': apiKey }
}
);
if (!response.ok) throw new Error(await response.text());
const clip = await response.json();
from urllib.parse import quote
import requests
api_key = 'YOUR_SERVER_APP_API_KEY'
clip_external_id = 'example-clip'
category_external_id = 'example-category'
response = requests.delete(
f'http://localhost:7071/api/clips/{quote(clip_external_id, safe="")}/categories/{quote(category_external_id, safe="")}',
headers={'x-storyteller-api-key': api_key},
timeout=30
)
response.raise_for_status()
clip = response.json()
using System.Net.Http;
using System.Text.Json;
using var client = new HttpClient();
var clipExternalId = "example-clip";
var categoryExternalId = "example-category";
using var request = new HttpRequestMessage(HttpMethod.Delete,
$"http://localhost:7071/api/clips/{Uri.EscapeDataString(clipExternalId)}/categories/{Uri.EscapeDataString(categoryExternalId)}");
request.Headers.Add("x-storyteller-api-key", "YOUR_SERVER_APP_API_KEY");
using var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
using var clip = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
curl --request DELETE \
'http://localhost:7071/api/clips/example-clip/categories/example-category' \
--header 'x-storyteller-api-key: YOUR_SERVER_APP_API_KEY'
Retries and Propagation#
A successful response confirms the removal is committed and refresh work has been accepted. Search, Collection caches and other derived views update asynchronously.
For 409 Conflict with clip_state_conflict, read the current Clip state before retrying the same DELETE. If the code is clip_external_id_ambiguous, identify the intended Clip and use its GUID fallback. A 500 Internal Server Error or a lost response can occur after the removal has committed: read back the state, then retry the same DELETE if needed to request reconciliation. Retries do not guarantee exactly-once webhook delivery.
| Status | Meaning |
|---|---|
400 Bad Request |
Blank/overlong Clip external ID or surrounding whitespace, empty fallback Clip GUID, or non-canonical Category external ID. |
401 Unauthorized |
Missing or invalid Server App API key. |
404 Not Found |
The Clip or Category is unavailable in the authenticated tenant. The detail is Clip or Category not found. |
409 Conflict |
Multiple Clips match the external ID (clip_external_id_ambiguous), or the Clip changed concurrently (clip_state_conflict). Use the recovery described above. |
500 Internal Server Error |
The request failed, potentially after commit. Read back before retrying. |
Error Handling#
All clip endpoints return RFC 7807 Problem Details errors. Example responses:
GET /api/clips returns 400 Bad Request when publishStatus is not All or a comma-separated list of the documented management values. Internal names such as Past, the former Expired value, numeric enum values, unknown strings, empty list items, and All combined with other values are not accepted. Use Archived for Clips whose availability window has ended.
For viewer-facing single-item reads, 404 Not Found can mean that the Clip does not exist or that it is not currently available. This avoids exposing unavailable content through those lookups. Category assignment and removal use the tenant/resource rules described above and accept unavailable editorial states.
{
"status": 401,
"type": "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1",
"title": "Unauthorized",
"detail": "No valid API key provided",
"instance": ""
}
{
"status": 404,
"type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
"title": "Not Found",
"detail": "Clip with externalId clip-999 not found",
"instance": ""
}
Best Practices#
- Serve media from Storyteller CDN – the API already resolves relative paths; use the URLs as-is to avoid hardcoding tenants.
- Prefer
externalIdfilters when you are looking up individual clips from your internal systems, andupdatedSincefor scheduled reconciliation. - Surface collection context so operators can see how clips are grouped inside Storyteller.
- Handle Problem Details responses uniformly across all content endpoints.
- Use Clip Analytics for engagement metrics rather than computing totals yourself – see Clip Analytics.
Related Documentation#
- Clip Analytics – Retrieve views, loops, and engagement KPIs
- Stories API – Complementary story metadata
- Collections – Discover related clip groups
- Authentication – Required header reference