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. Values below 1 are clamped to 1. Ignored when legacy pagination is used. |
pageSize |
integer | No | 10 | Number of items per page, clamped to 1–1000. Ignored when legacy pagination is used. |
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 | AlphabeticalAsc |
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.
Each list item contains the same category fields as Get Category by External ID, plus the legacy aliases id and name. id equals externalId; it is not an internal category GUID. name equals title. Existing consumers can continue using the aliases; new consumers can use the same field names for list and detail responses.
The list includes categories with either value of availableForNavigation. Read that flag directly from each item to display or filter navigation settings without fetching each category separately. Follow the pagination fields to retrieve all matching categories.
Response#
Success Response (200 OK)#
{
"categories": [
{
"id": "example-topic",
"name": "Example topic",
"externalId": "example-topic",
"title": "Example topic",
"type": "other",
"displayTitle": null,
"description": null,
"availableForNavigation": false,
"shouldLocalize": true
},
{
"id": "example-player",
"name": "Example player",
"externalId": "example-player",
"title": "Example player",
"type": "player",
"displayTitle": "Example Player",
"description": "Example player category",
"availableForNavigation": true,
"shouldLocalize": false
}
],
"pageSize": 10,
"currentPage": 1,
"totalPages": 1,
"totalCount": 2
}
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 | Legacy alias of externalId, retained for compatibility. Not an internal GUID. |
name |
string | Legacy alias of title, retained for compatibility. |
externalId |
string | Stable external identifier; use it in the category detail route. |
title |
string | Tenant-unique category title. |
type |
string | Dynamic category-type code, matching the detail response. |
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. false values are included. |
shouldLocalize |
boolean | Whether Storyteller should localize the category. Defaults to true when no value is stored. |
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#
Run these examples from a secure backend using a Server App key.
# Read the first page, including navigation state for each category.
curl "https://integrations.usestoryteller.com/api/categories?currentPage=1&pageSize=50" \
--header "x-storyteller-api-key: your-api-key-here"
# Read one category by its external ID.
curl "https://integrations.usestoryteller.com/api/categories/example-topic" \
--header "x-storyteller-api-key: your-api-key-here"
async function getCategories(currentPage = 1, pageSize = 50) {
const params = new URLSearchParams({
currentPage: String(currentPage),
pageSize: String(pageSize)
});
const response = await fetch(
`https://integrations.usestoryteller.com/api/categories?${params}`,
{ headers: { 'x-storyteller-api-key': process.env.STORYTELLER_API_KEY } }
);
if (!response.ok) {
throw new Error(`Categories request failed: HTTP ${response.status}`);
}
return response.json();
}
async function getAllCategories() {
const categories = [];
let currentPage = 1;
let totalPages;
do {
const page = await getCategories(currentPage);
categories.push(...page.categories);
totalPages = page.totalPages;
currentPage++;
} while (currentPage <= totalPages);
return categories;
}
const categories = await getAllCategories();
const navigationRows = categories.map(category => ({
externalId: category.externalId,
title: category.title,
availableForNavigation: category.availableForNavigation
}));
console.table(navigationRows);
import os
import requests
def get_all_categories():
categories = []
current_page = 1
while True:
response = requests.get(
'https://integrations.usestoryteller.com/api/categories',
headers={'x-storyteller-api-key': os.environ['STORYTELLER_API_KEY']},
params={'currentPage': current_page, 'pageSize': 50},
timeout=30,
)
response.raise_for_status()
page = response.json()
categories.extend(page['categories'])
if current_page >= page['totalPages']:
return categories
current_page += 1
for category in get_all_categories():
print(category['externalId'], category['title'], category['availableForNavigation'])
using System.Net.Http.Json;
using var client = new HttpClient();
client.DefaultRequestHeaders.Add(
"x-storyteller-api-key",
Environment.GetEnvironmentVariable("STORYTELLER_API_KEY"));
var currentPage = 1;
CategoriesResponse page;
do
{
page = await client.GetFromJsonAsync<CategoriesResponse>(
$"https://integrations.usestoryteller.com/api/categories?currentPage={currentPage}&pageSize=50")
?? throw new InvalidOperationException("Empty categories response");
foreach (var category in page.Categories)
{
Console.WriteLine($"{category.ExternalId}: {category.AvailableForNavigation}");
}
currentPage++;
} while (currentPage <= page.TotalPages);
public sealed record CategoriesResponse(
Category[] Categories, int PageSize, int CurrentPage, int TotalPages, int TotalCount);
public sealed record Category(
string Id,
string Name,
string ExternalId,
string Title,
string Type,
string? DisplayTitle,
string? Description,
bool AvailableForNavigation,
bool ShouldLocalize);
List Errors#
Errors use Problem Details. Missing or invalid Server App authentication returns 401 Unauthorized. Invalid legacy page alignment or a page offset that is too large returns 400 Bad Request.
Numeric page sizes are clamped to 1–1000; a page size above the maximum does not return a validation error. Requests with no matching categories return 200 OK with an empty categories array and totalCount: 0.
Related Documentation#
- Stories API — Stories also use categories for organization
- Clips API — Clips are organized using categories
- Executing Workflows — Workflow metadata
- Authentication — API key setup and usage