Skip to content

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 OK and 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 ETag or If-Match conditional 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 + pageSize or legacy-style skipCount + maxResultCount.

In legacy mode, omitted aliases fall back to skipCount=0 and maxResultCount=10. Because integrations responses still return currentPage, skipCount must 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.