Categories API#
The Categories endpoints allow you to retrieve existing categories and synchronize categories that your server owns in your Storyteller tenant. Categories are used for content organization and can be required metadata for certain workflows that need a categoryId.
Endpoints#
Get All Categories#
GET https://integrations.usestoryteller.com/api/categories
Get Category by External ID#
GET https://integrations.usestoryteller.com/api/categories/{externalId}
Create or Update a Category#
PUT https://integrations.usestoryteller.com/api/categories/{externalId}
Delete a Category#
DELETE https://integrations.usestoryteller.com/api/categories/{externalId}
Headers#
| Header | Required | Description |
|---|---|---|
x-storyteller-api-key |
Yes | Your API key for authentication |
Upsert Category#
Use this endpoint from a secure backend when your system owns a stable category external ID. A missing category is created; an existing category at the same external ID is updated or confirmed unchanged.
The API key must belong to a Server App. Storyteller derives the tenant from that key, so do not send a tenant parameter. Do not call this endpoint from browser code: Server App keys are secrets and browser PUT CORS is not enabled for this route.
Path Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
externalId |
string | Yes | Stable, canonical resource identifier. Use lowercase characters and URL-safe separators such as hyphens. Uppercase characters, whitespace, and reserved characters are rejected rather than normalized. |
Request Body#
| Field | Type | Required on create | Description |
|---|---|---|---|
title |
string | Yes | Category title. It is trimmed and must be unique among active categories in the tenant. Optional on update. |
type |
string | Yes | Enabled category-type code in the tenant, such as player. Unknown or unavailable type codes are rejected. Optional on update. |
displayTitle |
string or null | No | Display title shown to users. Send null to clear it. |
description |
string or null | No | Category description. Send null to clear it. |
availableForNavigation |
boolean | No (defaults to true) |
Whether clients can use the category for navigation. |
shouldLocalize |
boolean | No (defaults to true) |
Whether Storyteller should localize the category. |
curl --request PUT \
"https://integrations.usestoryteller.com/api/categories/player-plan-smoke-001" \
--header "x-storyteller-api-key: your-api-key-here" \
--header "Content-Type: application/json" \
--data '{
"title": "Example Player",
"type": "player",
"displayTitle": "Example Player",
"description": "Example player category",
"availableForNavigation": true,
"shouldLocalize": true
}'
The route external ID is the category identity and cannot be changed by the request. For an existing category, only properties present in the JSON body are changed. Omitted properties—including title and type—retain their stored values. Explicit null clears displayTitle or description; title, type, availableForNavigation, and shouldLocalize cannot be null when present.
For example, this changes only the description:
{
"description": "Updated example player category"
}
The endpoint manages only these six properties. External ID, followability, schedules, placement, assets, ordering, CDN configuration, and other CMS-managed properties are preserved.
Success Responses#
A new category returns 201 Created, including a relative Location header:
HTTP/1.1 201 Created
Location: /api/categories/player-plan-smoke-001
An updated or unchanged category returns 200 OK. Both statuses return the final endpoint-owned representation:
{
"externalId": "player-plan-smoke-001",
"title": "Example Player",
"type": "player",
"displayTitle": "Example Player",
"description": "Example player category",
"availableForNavigation": true,
"shouldLocalize": true
}
The category row is committed before a success response. Related cache, search, localization, and content projections are reconciled asynchronously and may take a short time to converge.
Errors#
Errors use Problem Details.
| Status | Meaning |
|---|---|
400 Bad Request |
The JSON is missing or malformed, a field or route external ID is invalid, a new category omits title or type, or the category type is not available in the tenant. |
401 Unauthorized |
The Server App key is missing or invalid. |
409 Conflict |
A different active category in the tenant already owns the requested title. |
500 Internal Server Error |
An unexpected persistence or asynchronous reconciliation-dispatch failure occurred. The PUT can be retried safely. |
A title conflict includes the stable code category_title_in_use:
{
"type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.8",
"title": "Conflict",
"status": 409,
"detail": "Another category in this tenant already uses the requested title.",
"instance": "",
"code": "category_title_in_use"
}
An existing category at the route external ID is the update target, not a conflict just because one of its exposed properties differs.
Idempotence and Concurrent Updates#
- Repeating the same PUT does not create a duplicate. A retry against an existing matching category returns
200 OKand reconciles its derived projections again. - If another request creates the route resource first, this request updates or confirms that resource.
- If another external ID owns the requested title, the request returns
409 category_title_in_use; Storyteller does not silently rename either category. - Concurrent PUTs with overlapping property changes use last-committed-write-wins semantics. Each request preserves properties it omits. This endpoint does not currently support
ETagorIf-Matchconditional updates.
Delete Category#
Use this endpoint from a secure backend to permanently remove the category identified by a canonical external ID. The API key must belong to a Server App. Storyteller derives the tenant from that key, so do not send a tenant parameter or a request body. Browser DELETE CORS is not enabled because Server App keys are secrets.
curl --request DELETE \
"https://integrations.usestoryteller.com/api/categories/player-plan-smoke-001" \
--header "x-storyteller-api-key: your-api-key-here"
The external ID follows the same canonical route rules as PUT: use lowercase characters and URL-safe separators such as hyphens. Uppercase characters, whitespace, and reserved characters are rejected rather than normalized.
Delete Behavior#
Deletion is permanent. It removes the category and its assignments from associated stories, pages, clips, cards, and collections. It does not delete any of those stories, pages, clips, cards, or collections.
The category row is committed before a success response. Related category caches and affected content projections are refreshed asynchronously through the same background processing used by CMS category deletion.
Concurrent PUT and DELETE requests use last-committed-write-wins semantics. If an earlier PUT reconciliation runs after a later DELETE has removed the category, it completes without recreating the category; the DELETE reconciliation handles the removed assignments.
Delete Success Response#
HTTP/1.1 204 No Content
The response has no body. A repeated DELETE after the category has gone returns 404 Not Found, rather than another 204.
Delete Errors#
| Status | Meaning |
|---|---|
400 Bad Request |
The route external ID is missing, overlong, or non-canonical. |
401 Unauthorized |
The Server App key is missing or invalid. |
404 Not Found |
No active category with that external ID exists in this tenant. |
409 Conflict |
Protected configuration still references the category, so Storyteller cannot safely hard-delete it. No deletion is committed. |
500 Internal Server Error |
An unexpected persistence or asynchronous reconciliation-dispatch failure occurred. |
A blocked deletion includes the stable code category_delete_blocked:
{
"type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.8",
"title": "Conflict",
"status": 409,
"detail": "The category cannot be deleted because it is referenced by protected configuration.",
"instance": "",
"code": "category_delete_blocked"
}
If DELETE returns 500, check the category with GET /api/categories/{externalId} before retrying. If GET returns 404, the category row was removed but its asynchronous reconciliation may require support assistance; repeatedly issuing DELETE will continue to return 404.
Get All Categories#
Query Parameters#
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
searchText |
string | No | - | Filter categories by name |
externalId |
string | No | - | Filter categories by external ID |
currentPage |
integer | No | 1 | Page number for pagination. Ignored when skipCount or maxResultCount is supplied. |
pageSize |
integer | No | 10 | Number of items per page. Must be between 1 and 1000. Ignored when skipCount or maxResultCount is supplied. |
skipCount |
integer | No | 0 | Legacy offset-style pagination alias. Defaults to 0 when omitted while using legacy pagination. |
maxResultCount |
integer | No | 10 | Legacy offset-style page-size alias. Defaults to 10 when omitted while using legacy pagination. Maximum 1000. |
sort |
string | No | - | Sort order: AlphabeticalAsc, LastModifiedDesc |
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.
Response#
Success Response (200 OK)#
{
"categories": [
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"title": "Entertainment",
"externalId": "entertainment",
"description": "Entertainment content and media",
"color": "#FF6B35",
"itemCount": 25,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-20T14:22:00Z"
},
{
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"title": "Education",
"externalId": "education",
"description": "Educational and learning content",
"color": "#4ECDC4",
"itemCount": 12,
"createdAt": "2024-01-10T08:15:00Z",
"updatedAt": "2024-01-18T16:45:00Z"
}
],
"pageSize": 10,
"currentPage": 1,
"totalPages": 1,
"totalCount": 1
}
Get Category by External ID#
Path Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
externalId |
string | Yes | The external ID of the category |
Response (200 OK)#
Returns the Integrations API-managed category representation:
{
"externalId": "player-plan-smoke-001",
"title": "Example Player",
"type": "player",
"displayTitle": "Example Player",
"description": "Example player category",
"availableForNavigation": true,
"shouldLocalize": true
}
| Field | Type | Description |
|---|---|---|
externalId |
string | Stable external identifier for the category. |
title |
string | Tenant-unique category title. |
type |
string | Dynamic category-type code. |
displayTitle |
string or null | Display title shown to users. |
description |
string or null | Category description. |
availableForNavigation |
boolean | Whether clients can use the category for navigation. |
shouldLocalize |
boolean | Whether Storyteller should localize the category. |
Response (404 Not Found)#
{
"status": 404,
"type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
"title": "Not Found",
"detail": "Category with externalId entertainment not found",
"instance": ""
}
Get All Categories Response Fields#
Category Object#
| Field | Type | Description |
|---|---|---|
id |
string | Unique category identifier (use this for categoryId in workflow metadata) |
title |
string | Category display name |
externalId |
string | External identifier for the category |
description |
string | Category description (may be null) |
color |
string | Hex color code for category display |
itemCount |
integer | Number of items assigned to this category |
createdAt |
string | ISO timestamp when category was created |
updatedAt |
string | ISO timestamp when category was last modified |
Pagination Object#
| Field | Type | Description |
|---|---|---|
pageSize |
integer | Number of items per page |
currentPage |
integer | Current page number |
totalPages |
integer | Total number of pages available |
totalCount |
integer | Exact total number of matching categories |
Code Examples#
# Get all categories (first page)
curl -X GET "https://integrations.usestoryteller.com/api/categories" \
-H "x-storyteller-api-key: your-api-key-here"
# Search for specific categories
curl -X GET "https://integrations.usestoryteller.com/api/categories?searchText=entertainment&pageSize=20" \
-H "x-storyteller-api-key: your-api-key-here"
```javascript const fetch = require('node-fetch');
async function getCategories(searchText = '', currentPage = 1, pageSize = 10) { const params = new URLSearchParams({ ...(searchText && { searchText }), currentPage: currentPage.toString(), pageSize: pageSize.toString() });
try {
const response = await fetch(https://integrations.usestoryteller.com/api/categories?${params}, {
method: 'GET',
headers: {
'x-storyteller-api-key': process.env.STORYTELLER_API_KEY
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`HTTP ${response.status}: ${errorData.message}`);
}
const data = await response.json();
return data;
} catch (error) { console.error('Error fetching categories:', error); throw error; } }
// Usage examples
async function examples() {
// Get all categories
const allCategories = await getCategories();
console.log(Found ${allCategories.categories.length} categories);
// Search for specific categories
const searchResults = await getCategories('entertainment', 1, 20);
console.log(Found ${searchResults.categories.length} categories matching "entertainment");
// Get category IDs for workflow metadata const categoryIds = allCategories.categories.map(cat => cat.id); console.log('Available category IDs:', categoryIds);
// Find popular categories (by item count) const popularCategories = allCategories.categories .sort((a, b) => b.itemCount - a.itemCount) .slice(0, 3); console.log('Most popular categories:', popularCategories.map(cat => ({ title: cat.title, itemCount: cat.itemCount }))); }
=== "Python"
```python
import requests
import os
from urllib.parse import urlencode
def get_categories(search_text='', current_page=1, page_size=10):
url = 'https://integrations.usestoryteller.com/api/categories'
headers = {
'x-storyteller-api-key': os.environ.get('STORYTELLER_API_KEY')
}
params = {
'currentPage': current_page,
'pageSize': page_size
}
if search_text:
params['searchText'] = search_text
try:
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
data = response.json()
return data
except requests.exceptions.RequestException as e:
print(f'Error fetching categories: {e}')
if hasattr(e.response, 'json'):
print(f'Error details: {e.response.json()}')
raise
# Usage examples
try:
# Get all categories
all_categories = get_categories()
print(f'Found {len(all_categories["categories"])} categories')
# Search for specific categories
search_results = get_categories(search_text='education', page_size=20)
print(f'Found {len(search_results["categories"])} categories matching "education"')
# Extract category IDs for workflow usage
category_ids = [cat['id'] for cat in all_categories['categories']]
print('Available category IDs:', category_ids)
# Find categories by external ID
education_cat = next(
(cat for cat in all_categories['categories'] if cat['externalId'] == 'education'),
None
)
if education_cat:
print(f'Education category ID: {education_cat["id"]}')
except Exception as e:
print(f'Failed to fetch categories: {e}')
```csharp
using System; using System.Net.Http; using System.Threading.Tasks; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json;
public class CategoriesClient { private readonly HttpClient _httpClient; private readonly string _baseUrl = "https://integrations.usestoryteller.com";
public CategoriesClient(string apiKey)
{
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("x-storyteller-api-key", apiKey);
}
public async Task<CategoriesResponse> GetCategoriesAsync(string searchText = "", int currentPage = 1, int pageSize = 10)
{
try
{
var queryParams = new List<string>
{
$"currentPage={currentPage}",
$"pageSize={pageSize}"
};
if (!string.IsNullOrEmpty(searchText))
{
queryParams.Add($"searchText={Uri.EscapeDataString(searchText)}");
}
var queryString = string.Join("&", queryParams);
var response = await _httpClient.GetAsync($"{_baseUrl}/api/categories?{queryString}");
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync();
var categories = JsonConvert.DeserializeObject<CategoriesResponse>(responseContent);
return categories;
}
catch (HttpRequestException ex)
{
throw new Exception($"Error fetching categories: {ex.Message}", ex);
}
}
}
public class CategoriesResponse { public Category[] Categories { get; set; } public int PageSize { get; set; } public int CurrentPage { get; set; } public int TotalPages { get; set; } }
public class Category { public string Id { get; set; } public string Title { get; set; } public string ExternalId { get; set; } public string Description { get; set; } public string Color { get; set; } public int ItemCount { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } }
// Usage var client = new CategoriesClient(Environment.GetEnvironmentVariable("STORYTELLER_API_KEY"));
try { // Get all categories var allCategories = await client.GetCategoriesAsync(); Console.WriteLine($"Found {allCategories.Categories.Length} categories");
// Search for specific categories
var searchResults = await client.GetCategoriesAsync("entertainment", 1, 20);
Console.WriteLine($"Found {searchResults.Categories.Length} categories matching 'entertainment'");
// Find most popular categories
var popularCategories = allCategories.Categories
.OrderByDescending(c => c.ItemCount)
.Take(3)
.ToArray();
Console.WriteLine("Most popular categories:");
foreach (var category in popularCategories)
{
Console.WriteLine($" {category.Title}: {category.ItemCount} items");
}
} catch (Exception ex) { Console.WriteLine($"Error: {ex.Message}"); } ```
Usage with Workflows#
When you have workflows that require a categoryId in their metadata, use this endpoint to get the available category IDs:
javascript
// 1. Get available categories
const categoriesData = await getCategories();
// 2. Find category by external ID
const entertainmentCategory = categoriesData.categories.find(
cat => cat.externalId === 'entertainment'
);
// 3. Use category ID in workflow metadata
const workflowMetadata = {
'https://example.com/video.mp4': {
'Title': 'My Entertainment Video',
'categoryId': entertainmentCategory.id
}
};
// 4. Execute workflow with category metadata
await executeWorkflow(['add-clip'], ['https://example.com/video.mp4'], workflowMetadata);
Advanced Usage Examples#
Category Management Dashboard#
class CategoryManager {
constructor(apiKey) {
this.apiKey = apiKey;
}
async getCategoryStatistics() {
const categories = await getAllCategories(); // Pagination function below
return {
totalCategories: categories.length,
totalItems: categories.reduce((sum, cat) => sum + cat.itemCount, 0),
averageItemsPerCategory: categories.reduce((sum, cat) => sum + cat.itemCount, 0) / categories.length,
mostPopularCategory: categories.reduce((max, cat) => cat.itemCount > max.itemCount ? cat : max),
emptyCategories: categories.filter(cat => cat.itemCount === 0),
colorDistribution: this.analyzeColors(categories)
};
}
analyzeColors(categories) {
const colorCount = {};
categories.forEach(cat => {
colorCount[cat.color] = (colorCount[cat.color] || 0) + 1;
});
return colorCount;
}
async findCategoryByExternalId(externalId) {
const categories = await getAllCategories();
return categories.find(cat => cat.externalId === externalId);
}
}
Content Organization Helper#
async function organizeContentByCategory() {
const categories = await getAllCategories();
const clips = await getAllClips(); // From clips API
// Create content map
const contentMap = {};
categories.forEach(category => {
contentMap[category.title] = {
category: category,
clips: clips.filter(clip =>
clip.categories.some(clipCat => clipCat.externalId === category.externalId)
)
};
});
// Find uncategorized content
const uncategorizedClips = clips.filter(clip => clip.categories.length === 0);
return {
categorizedContent: contentMap,
uncategorizedClips: uncategorizedClips,
summary: {
totalCategories: categories.length,
totalClips: clips.length,
uncategorizedCount: uncategorizedClips.length,
categorizationRate: ((clips.length - uncategorizedClips.length) / clips.length * 100).toFixed(1) + '%'
}
};
}
Pagination Example#
async function getAllCategories() {
let allCategories = [];
let currentPage = 1;
let totalPages = 1;
do {
const response = await getCategories('', currentPage, 50);
allCategories.push(...response.categories);
totalPages = response.totalPages;
currentPage++;
} while (currentPage <= totalPages);
return allCategories;
}
// Usage
const allCategories = await getAllCategories();
console.log(`Retrieved ${allCategories.length} total categories`);
Category Selection Helper#
async function selectCategoryForContent(contentType, contentTitle) {
const categories = await getCategories();
// Smart category suggestions based on content
const suggestions = categories.categories.filter(cat => {
const titleLower = contentTitle.toLowerCase();
const categoryLower = cat.title.toLowerCase();
return titleLower.includes(categoryLower) ||
categoryLower.includes(contentType.toLowerCase());
});
return {
allCategories: categories.categories,
suggestions: suggestions,
mostPopular: categories.categories.sort((a, b) => b.itemCount - a.itemCount).slice(0, 5)
};
}
// Usage
const categoryOptions = await selectCategoryForContent('video', 'Cooking Tutorial');
console.log('Suggested categories:', categoryOptions.suggestions.map(cat => cat.title));
Error Handling#
Common Error Responses#
{
"error": "Unauthorized",
"message": "Invalid or missing API key",
"statusCode": 401
}
{
"error": "Bad Request",
"message": "Invalid page size. Must be between 1 and 100",
"statusCode": 400
}
Best Practices#
- Cache Categories - Categories rarely change, consider caching for extended periods
- Use External IDs - External IDs are more stable than internal IDs for lookups
- Search Efficiently - Use search text to filter before client-side processing
- Handle Empty Results - Some tenants may have no categories configured
- Sort by Popularity - Use
itemCountto prioritize commonly used categories - Color Consistency - Use the provided color codes for consistent UI theming
Integration Patterns#
Workflow Metadata Builder#
class WorkflowMetadataBuilder {
constructor() {
this.categories = null;
}
async initialize() {
this.categories = await getAllCategories();
}
buildMetadata(mediaUrl, title, options = {}) {
const metadata = { Title: title };
if (options.categoryExternalId) {
const category = this.categories.find(cat => cat.externalId === options.categoryExternalId);
if (category) {
metadata.categoryId = category.id;
}
}
if (options.autoSuggestCategory) {
const suggestedCategory = this.suggestCategory(title);
if (suggestedCategory) {
metadata.categoryId = suggestedCategory.id;
}
}
return { [mediaUrl]: metadata };
}
suggestCategory(title) {
if (!this.categories) return null;
const titleLower = title.toLowerCase();
return this.categories.find(cat =>
titleLower.includes(cat.title.toLowerCase()) ||
titleLower.includes(cat.externalId.toLowerCase())
);
}
}
Related Documentation#
- Stories API - Stories also use categories for organization
- Clips API - Clips are organized using categories
- Executing Workflows - Use category IDs in workflow metadata
- Authentication - API key setup and usage