API Documentation

Django Cast provides a comprehensive REST API for content management, media handling, and theme customization. The API supports both traditional Django session authentication and enables headless CMS usage.

Overview

The API is available at /api/ and includes:

  • Media management (audio, video, images)

  • Content access via Wagtail API

  • Faceted search capabilities

  • Theme management

  • Comment moderation data export

Authentication

Authenticated endpoints use Django session authentication (for example /api/videos/, /api/audios/, and /api/comment_training_data/).

Browsable-API login/logout routes are project-specific. In the example project they are:

  • /api-auth/login/

  • /api-auth/logout/

Public endpoints (no authentication required) include:

  • Podlove audio format

  • Player configuration

  • Theme listing and theme update

  • Wagtail pages and images

  • Facet counts

Legacy API

The non-editor cast.api.views endpoints are a legacy API surface with frozen response contracts for existing clients. That includes POST /api/upload_video/ returning its historical bare-text "<pk>" body with 201 Created. New integrations should use the editor API below for structured errors, scoped authorization, and If-Match revision handling.

The legacy /api/videos/ and /api/audios/ collection endpoints are read-only. POST requests return 405 Method Not Allowed. Use POST /api/editor/media/videos/ or POST /api/editor/media/audios/ for permission-checked uploads. Existing legacy video clients can continue using POST /api/upload_video/ while they migrate.

Legacy mutations now apply scoped-token and Wagtail permission boundaries. POST /api/upload_video/ requires Wagtail admin access, an available video collection with add permission, and the write scope when the authenticating token advertises scopes. It also shares the editor audio/video per-user upload lock and cumulative probe budget. If more than one upload collection is available, submit its integer ID as collection. DELETE /api/audios/{id}/ and DELETE /api/videos/{id}/ retain owner isolation and additionally require Wagtail admin access, delete permission for the object’s collection, and the distinct delete scope for scoped tokens. For deletes, an empty, malformed, write-only, or otherwise insufficient token scope is denied before mutation. Session authentication and tokens without any scope metadata defer to Wagtail permissions.

This hardening changes legacy upload error contracts: unauthenticated requests return 403 instead of redirecting to login, validation errors use the editor validation_error envelope, and a busy upload lock returns 429 with code rate_limited. The compatibility endpoint keeps title optional even when the configured video form normally requires it. Other site-specific required video-form fields remain required; sites using them should migrate clients to the editor endpoint.

Endpoints

Media Management

Videos

List videos:

GET /api/videos/

Retrieve or delete a video:

GET /api/videos/{id}/
DELETE /api/videos/{id}/

Upload video file:

POST /api/upload_video/

Example response:

{
    "id": 1,
    "url": "http://localhost:8000/api/videos/1/",
    "original": "http://localhost:8000/media/cast_videos/demo.mp4",
    "poster": "http://localhost:8000/media/cast_videos/poster/poster_abcd.jpg",
    "poster_thumbnail": "http://localhost:8000/media/images/poster.width-300.jpg"
}

Audio

List audio files:

GET /api/audios/

Retrieve or delete audio:

GET /api/audios/{id}/
DELETE /api/audios/{id}/

Podlove player format (public):

GET /api/audios/podlove/{id}/
GET /api/audios/podlove/{id}/post/{post_id}/

Returns audio data formatted for the Podlove Web Player, including chapters, transcripts, and a top-level contributors list. Contributors are derived from non-blank transcript speaker/voice labels in first-appearance order; the Podlove Web Player resolves transcript segment speakers against them and renders their names:

"contributors": [
    {"id": "Speaker 1", "name": "Speaker 1"}
]

Audio list/detail responses include these serializer fields:

{
    "id": 12,
    "name": "Episode 12",
    "file_formats": "m4a mp3",
    "url": "http://localhost:8000/api/audios/12/",
    "podlove": "http://localhost:8000/api/audios/podlove/12/",
    "mp3": "http://localhost:8000/media/cast_audio/episode-12.mp3"
}

Player configuration:

GET /api/audios/player_config/
GET /api/audios/player_config/?color_scheme=dark

Returns player theme and configuration settings. The color_scheme query parameter accepts light or dark and can be used by themes with client-side color mode switching.

Content Access

Wagtail Pages API

Access page content with filtering:

GET /api/wagtail/pages/
GET /api/wagtail/pages/{id}/

Standard Wagtail filters:

  • type: Filter by page type (e.g., cast.Post, cast.Episode)

  • child_of: Filter to only include direct children of the page with this ID

  • descendant_of: Filter to include all descendants of the page with this ID

  • translation_of: Filter to only include translations of the page with this ID

  • fields: Comma-separated list of fields to include in response

  • order: Ordering field (prefix with - for descending)

  • slug: Filter by slug (exact match)

  • show_in_menus: Filter pages shown in menus

  • search: Full-text search (note: when using use_post_filter=true, this uses Django Cast’s enhanced search)

  • limit: Number of results per page

  • offset: Number of results to skip

Django Cast custom filters (requires use_post_filter=true):

  • use_post_filter: Set to true to enable Django Cast’s enhanced filtering

  • date_facets: Filter by year-month (format: YYYY-MM)

  • category_facets: Filter by category slug

  • tag_facets: Filter by tag slug

  • date_after: Filter posts after date (format: YYYY-MM-DD)

  • date_before: Filter posts before date (format: YYYY-MM-DD)

  • o: Ordering by visible_date (use -visible_date for descending)

Example requests:

Standard Wagtail filtering:

GET /api/wagtail/pages/?type=cast.Post&child_of=4

Enhanced Django Cast filtering:

GET /api/wagtail/pages/?type=cast.Post&use_post_filter=true&category_facets=tech&date_after=2024-01-01

Filter posts by month:

GET /api/wagtail/pages/?type=cast.Post&use_post_filter=true&date_facets=2024-03

Combined filters:

GET /api/wagtail/pages/?type=cast.Post&child_of=4&use_post_filter=true&tag_facets=python&date_facets=2024-03

Images API

Access images (active staff users only by default):

GET /api/wagtail/images/
GET /api/wagtail/images/{id}/

This is Wagtail’s stock images endpoint. It lists every image in unrestricted collections, including images only used by draft or private posts, and each item’s meta.download_url points at the unprocessed original upload (which may still carry EXIF metadata such as GPS coordinates). It therefore requires an authenticated, active staff user; anonymous and non-staff callers receive 403 (or 401 when the first configured authentication class sends an authentication challenge). Responses are marked Cache-Control: private, no-store and vary on Cookie and Authorization. Wagtail still applies collection view restrictions per request, so images in a restricted collection are only listed for callers who pass that restriction (a logged-in staff user passes a login restriction, for example).

Set CAST_PUBLIC_IMAGES_API to True to restore the previous anonymous access.

Search and Discovery

Facet Counts

List blogs that expose the detail endpoint:

GET /api/facet_counts/

If cast is mounted at /cast/, this becomes /cast/api/facet_counts/.

Example list response:

{
    "count": 1,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 4,
            "url": "http://localhost:8000/cast/api/facet_counts/4/"
        }
    ]
}

Detail endpoint supports two response modes:

Legacy mode (default)

Request:

GET /api/facet_counts/{blog_id}/

Optional filter params are passed through the same filterset as the blog list: search, date_after, date_before, date_facets, category_facets, tag_facets, o.

Example:

GET /api/facet_counts/4/?search=python&tag_facets=django

Legacy response shape:

{
    "id": 4,
    "url": "http://localhost:8000/cast/api/facet_counts/4/",
    "facet_counts": {
        "date_facets": [
            {"slug": "2026-02", "name": "2026-02", "count": 2}
        ],
        "category_facets": [
            {"slug": "til", "name": "Today I Learned", "count": 1}
        ],
        "tag_facets": [
            {"slug": "django", "name": "django", "count": 1}
        ]
    }
}

Title-prefix suggestions

Return a bounded list of public post destinations whose titles match a prefix:

GET /api/search-suggestions/{blog_id}/?search=py

The optional date_facets, tag_facets, and category_facets params constrain suggestions in the same way as the blog filters. Search text is normalized through django-cast’s modelsearch guard. Queries shorter than two characters return an empty list.

Example response:

{
    "query": "py",
    "suggestions": [
        {
            "id": 42,
            "title": "Python performance",
            "url": "/blog/python-performance/",
            "visible_date": "2026-07-10T09:00:00+00:00"
        }
    ]
}

Suggestions match titles only, are ordered by most recently published first, and are capped at eight. They are destinations rather than a preview of the committed full-text result set: selecting one should navigate directly to its url. The response is marked private, no-store. Themes can discover the blog-scoped endpoint through page.search_suggestions_api_url and treat it as a progressive enhancement of the existing GET search form.

Theme Management

List available themes:

GET /api/themes/

Update selected theme:

POST /api/update_theme/
Content-Type: application/json

{"theme_slug": "bootstrap5"}

The theme-update endpoint is session-based and does not require authentication with django-cast’s default DRF configuration. It validates the submitted theme slug and stores the selection using the same theme-selection flow as the frontend theme switcher.

Comment Moderation

Export training data for spam filter:

GET /api/comment_training_data/

Returns comment data for training the Naive Bayes spam classifier.

This endpoint is restricted to staff users. Anonymous and authenticated non-staff users receive 403 Forbidden.

Content Editing (Editor API)

The editor API lives under /api/editor/ and is intended for trusted clients — scripts, agents, or headless tools — that need to create draft content without direct database access. It is authentication-mechanism agnostic: any DRF authentication class that populates request.user works. Django-cast ships session authentication by default. Editor endpoints require an authenticated user with Wagtail admin access and then apply Wagtail page or collection permissions for the requested object.

List editable parents:

GET /api/editor/parents/

Returns the Blog and Podcast pages the authenticated user has add-child permission for. Requires authentication; unauthenticated requests are rejected (403 Forbidden under the default session authentication, 401 for authentication schemes that supply a WWW-Authenticate challenge).

Example response:

[
    {"id": 4, "title": "My Blog", "type": "cast.Blog",
     "api_url": "/api/editor/posts/"},
    {"id": 7, "title": "My Podcast", "type": "cast.Podcast",
     "api_url": "/api/editor/episodes/"}
]

api_url is the create endpoint hint for that parent’s primary content type: a cast.Blog points at /api/editor/posts/ and a cast.Podcast points at /api/editor/episodes/. Podcasts also accept plain posts; clients that want a post under a podcast can still POST /api/editor/posts/ with that podcast as the parent.

Podcast-level feed settings such as itunes_type are not edited through the editor API. Manage them on the podcast page in Wagtail or through the Django admin; editor API episode endpoints only manage episode-level publishing metadata.

Look up one editable post by parent and slug:

GET /api/editor/posts/?parent=123&slug=weeknotes-2026-25

Returns the normal editor post object for the exact Post direct child whose stored slug exactly matches slug under the exact Blog/Podcast parent id. Slug matching uses each post’s latest editable revision, not only the materialized live page row, so an unpublished slug edit is immediately findable under its new value. This is a deterministic single-object lookup, not a list or search endpoint, and it is not paginated. Both filters must occur exactly once: parent must be a positive integer and slug must be a valid slug. Unknown, duplicate, missing, or malformed filters return the standard validation_error envelope; an unknown parent or no exact direct-child match returns 404 not_found. Legacy data with more than one editable latest revision matching the same parent and slug fails closed as 409 ambiguous_lookup.

The caller must be authenticated, have Wagtail admin access, and have edit permission for the matching post. A matching post that the caller cannot edit returns 403 permission_denied and is never serialized. As with the id-based read endpoint, the response uses the latest editable revision. A page with an approved Wagtail publication schedule has status: "scheduled"; otherwise a live post with unpublished changes has live: true and status: "draft". This is a read-only action, requires no token scope, and does not expose publishing or change create behavior on the same collection URL.

Post creation performs a no-op write to the selected parent inside the same transaction as the sibling slug check and child insertion, then reloads the parent’s tree metadata. PostgreSQL therefore holds the parent row’s write lock; SQLite holds its database write lock. Concurrent creators for the same parent and slug serialize on both supported backends: one succeeds and the other receives the normal duplicate-slug validation response, which lookup-first clients can resolve deterministically (subject to the deployment’s normal database lock timeout rather than an application-level fallback).

Create a draft post:

POST /api/editor/posts/
Content-Type: application/json

Creates a draft Post under the chosen parent page. The page is saved as a Wagtail revision and is not published by this endpoint. Passing "publish": true is rejected; use the explicit publish action after review. Podcast episodes have their own create/read/update endpoints with the same shape plus episode-specific fields; see Episode endpoints below.

Referenced media — cover_image and inline image, gallery, audio, and video blocks — must be choosable by the caller. Missing and inaccessible media references are reported the same way (not_found) so the API does not leak objects outside the caller’s permissions.

paragraph HTML is sanitized on create and PATCH through Wagtail’s ContentState converter before normal block validation. The converter rebuilds HTML from the block’s rich-text features, using the selected editor’s OPTIONS.features or Wagtail’s default features when the block does not specify them. Disallowed elements, attributes, and link schemes are removed; their text may remain as ordinary text. Malformed input that cannot be converted returns 400 validation_error at the affected value path. There is no fallback to storing the original input.

The response contains normalized HTML, which can differ from the request: <p onclick="alert(1)">Text</p> becomes <p>Text</p>. Supplied Draftail data-block-key values are retained so existing rich-text comment anchors survive a round trip; API writes do not add generated keys to unkeyed HTML. Supported page and document links retain Wagtail’s internal reference format. Inline image and oEmbed features are excluded from API rich text, even if enabled in the admin; use the structured image/gallery/audio/video blocks with their permission and media checks. A submitted <embed> tag returns a field-specific validation error with code inline_embed rather than silently removing inline media. This also applies to resubmitting existing admin-authored paragraphs: move inline media to structured blocks before sending that section, or omit the section from PATCH. Rich-text conversion does not fetch remote embed providers.

Page/document links whose targets have been deleted retain their reference IDs on write, following Wagtail’s broken-link behavior; a missing target does not make an otherwise valid rich-text write fail.

Formatting outside the resolved feature list is reduced to ordinary text. For example, sites using Wagtail’s defaults must enable blockquote and code in their rich-text editor options if API writes should preserve those formats. Review the feature list before resubmitting existing content.

Warning

This protection applies to submitted rich text on new API writes. Upgrading does not sanitize existing page bodies, drafts, or historical revisions. GET and previews still use stored content, PATCH preserves omitted sections, and restoring an old revision can restore unsafe HTML. Sites that previously exposed this API should review existing content and restorable revisions, including published content, before removing temporary access restrictions. Resubmitting a section sanitizes its editable rich text; it does not clean preserved unsupported blocks, other sections, or revision history.

Request fields:

  • parent (required): {"id": <page id>} — must be a Blog or Podcast the caller may add to.

  • title (required): page title.

  • slug (optional): URL slug; auto-derived from title if omitted.

  • seo_title (optional): Wagtail Promote-tab title used for search and social previews.

  • search_description (optional): Wagtail Promote-tab description used for search and social previews.

  • visible_date (optional): ISO 8601 datetime string.

  • cover_image (optional): {"id": <image id>, "alt_text": "…"}.

  • tags (optional): list of tag name strings.

  • categories (optional): list of PostCategory IDs.

  • overview (required unless overview_markdown is sent): ordered list of body blocks (see below). Send [] when creating a post that only populates detail.

  • overview_markdown (optional): Markdown alternative to overview; see Markdown input.

  • detail (optional): ordered list of body blocks.

  • detail_markdown (optional): Markdown alternative to detail.

  • publish (optional): must be false or absent.

Body block types accepted in overview and detail:

[
    {"type": "paragraph", "value": "<h2>Notes</h2>"},
    {"type": "paragraph", "value": "<p>Rich-text HTML.</p>"},
    {"type": "code",      "value": {"language": "python",
                                    "source": "print('hi')"}},
    {"type": "image",     "value": {"id": 456}},
    {"type": "gallery",   "value": [{"id": 456}, {"id": 789}]},
    {"type": "audio",     "value": {"id": 42}},
    {"type": "video",     "value": {"id": 43}}
]

Sites can expose additional section-specific custom block types through CAST_POST_BODY_BLOCKS. For a configured custom block, clients send the block’s author-facing value directly, not Wagtail’s internal StreamField storage wrappers. For example, a custom ListBlock(StructBlock(...)) value is sent as a plain JSON list of objects; clients do not send {"type": "item", "id": "...", "value": ...} list-item wrappers. Custom block values are converted through the configured Wagtail block’s to_python(), clean(), and get_prep_value() methods on write, and are returned in an editor-facing representation on read. Before clean(), the API also sanitizes RichTextBlock values recursively inside StructBlock, ListBlock, and StreamBlock containers. Empty optional rich text stays empty. Submitted RawHTMLBlock values at the top level or nested in these three container types are rejected with a field-specific validation error. When multiple nested rich text leaves are invalid, the response reports every rejected leaf path together rather than stopping after the first one.

Custom blocks are a trusted site-level extension point. The editor API enforces the built-in media blocks’ per-user choose permissions, but it cannot infer permissions for arbitrary object references inside custom block values. A custom block that references images, pages, snippets, or media should enforce its own validation and permission semantics, or should not be exposed to editor API clients. Custom HTML rendering, custom block methods and rich-text converter plugins remain trusted application code. The API cannot infer HTML stored in arbitrary string fields or make unsafe custom rendering safe. Non-API writes and preserved unsupported placeholders are outside this write-time protection. Other custom container types, such as TypedTableBlock, are not traversed; their implementation must sanitize nested HTML itself, or the site must not expose them through this API.

Stored body blocks that this API version cannot safely edit are returned as {"type": "unsupported", "value": {"stored_type": "...", "position": "detail.0"}} placeholders. This includes unsupported block types such as embed, no-longer-configured custom blocks, configured custom blocks whose stored value can no longer be converted safely, and supported block types whose stored value cannot be represented as an editable authoring block, such as deleted or inaccessible media references, empty or malformed galleries, or malformed code blocks. To preserve one of these blocks while replacing a section, send the placeholder back with the same stored_type and position. The placeholder may move to a different index in the submitted section; position identifies the original stored block to preserve. Omitting a placeholder removes that stored block as part of the full-section replacement.

Markdown input (optional)

Clients that author in Markdown can send overview_markdown and/or detail_markdown instead of the matching block list on create and PATCH, for posts and episodes. The structured block list remains the canonical contract: Markdown is converted server-side into paragraph and code blocks, then validated and sanitized exactly like a submitted block list, and responses always return the block list (the Markdown source is not stored).

This input needs the optional markdown extra, which installs markdown-it-py:

uv pip install "django-cast[markdown]"

Without the extra, requests that use a *_markdown field return 400 validation_error with code markdown_unavailable at that field; block-list writes are unaffected and the parser is never imported.

Conversion policy:

  • Markdown is parsed as CommonMark. Consecutive top-level prose becomes one paragraph block; each top-level fenced or indented code block becomes a code block. The first word of the fence info string is the language, or text when absent. Code nested in lists or quotes stays in the paragraph.

  • Raw HTML in the Markdown source is escaped and kept as text, never passed through as markup. The parser leaves javascript:, vbscript:, file: and most data: links as literal text; any link that does become markup is still subject to the rich-text sanitizer’s link-scheme rules.

  • The generated paragraph HTML then goes through the same rich-text sanitizer as submitted paragraph blocks, so formatting outside the resolved feature list is reduced to text. With Wagtail’s default features, # headings become paragraphs and block quotes lose their quoting; enable the features you need.

  • Markdown images (![alt](url)) are rejected with code inline_image. The message names the source line, or line range, of each paragraph or heading that contains images. Use structured image/gallery blocks so media permission checks apply.

  • Sending both overview and overview_markdown (or both detail and detail_markdown) returns 400 validation_error with code conflict at the Markdown field. The two sections are independent, so one may use Markdown and the other a block list.

  • Errors in generated blocks are reported at <section>_markdown.<index>..., where the index refers to the generated block list, e.g. detail_markdown.1.value.source for an empty fenced code block.

  • A Markdown section replaces the whole section on PATCH, like a block list. It cannot carry unsupported placeholders, so stored unsupported blocks in that section are removed; send the block list to preserve them.

Example:

{
  "parent": {"id": 123},
  "title": "Weeknotes 2026-25",
  "overview_markdown": "## Notes\n\nShipped the *first* draft.\n\n```python\nprint(\"hello\")\n```\n"
}

stores the same overview as:

[
  {"type": "paragraph", "value": "<h2>Notes</h2><p>Shipped the <i>first</i> draft.</p>"},
  {"type": "code", "value": {"language": "python", "source": "print(\"hello\")"}}
]

Full create request example:

{
  "parent": {"id": 123},
  "title": "Weeknotes 2026-25",
  "slug": "weeknotes-2026-25",
  "seo_title": "Weeknotes 2026-25",
  "search_description": "A concise summary of this week's main theme.",
  "visible_date": "2026-06-19T18:00:00+02:00",
  "cover_image": {"id": 456, "alt_text": "Notebook and laptop on a desk"},
  "tags": ["weeknotes"],
  "categories": [],
  "overview": [
    {"type": "paragraph", "value": "<h2>Notes</h2>"},
    {"type": "paragraph", "value": "<p>Shipped the first draft.</p>"},
    {"type": "code",      "value": {"language": "python",
                                    "source": "print(\"hello\")"}},
    {"type": "gallery",   "value": [{"id": 456}, {"id": 789}]}
  ],
  "detail": [
    {"type": "video", "value": {"id": 43}}
  ],
  "publish": false
}

Success response (201 Created):

{
  "id": 987,
  "type": "cast.Post",
  "title": "Weeknotes 2026-25",
  "slug": "weeknotes-2026-25",
  "page_slug": "weeknotes-2026-25",
  "seo_title": "Weeknotes 2026-25",
  "search_description": "A concise summary of this week's main theme.",
  "parent": {"id": 123},
  "visible_date": "2026-06-19T18:00:00+02:00",
  "tags": ["weeknotes"],
  "categories": [],
  "cover_image": {"id": 456, "alt_text": "Notebook and laptop on a desk"},
  "overview": [
    {"type": "paragraph", "value": "<h2>Notes</h2>"},
    {"type": "paragraph", "value": "<p>Shipped the first draft.</p>"},
    {"type": "code",      "value": {"language": "python",
                                    "source": "print(\"hello\")"}},
    {"type": "gallery",   "value": [{"id": 456}, {"id": 789}]}
  ],
  "detail": [
    {"type": "video", "value": {"id": 43}}
  ],
  "latest_revision_id": 6543,
  "previous_revision_id": null,
  "live": false,
  "status": "draft",
  "preview_url": "/admin/pages/987/view_draft/",
  "edit_url": "/admin/pages/987/edit/",
  "api_url": "/api/editor/posts/987/"
}

previous_revision_id is content-free, page-local recovery and concurrency evidence. It is the positive id of the revision immediately preceding the serialized latest_revision_id for that same page, or null when the serialized revision is the page’s first revision. Global revision ids are not consecutive per page, so clients must not derive this value by subtracting one. For example, a client recovering an atomic title/slug migration can require both the expected current identity and previous_revision_id equal to its expected base revision before treating the latest revision as its single immediate write. A later edit that retains the migrated identity will no longer satisfy that predecessor check. This field reveals no revision content or additional history metadata beyond that one predecessor id. It is evidence only: it is not an authorization token and does not replace editor authentication, Wagtail permissions, or the PATCH revision precondition.

page_slug is the slug persisted on Wagtail’s page row, while slug is the slug in the serialized editable revision. They are equal for unpublished pages created or renamed through the editor API. A live page with an unpublished rename keeps its public row slug in page_slug until publication, while slug reports the editable draft value.

Read a draft post:

GET /api/editor/posts/{id}/

Returns editable metadata, normalized overview and detail block lists (same structure as the create request), revision metadata, and admin URLs for an existing draft. Missing sections are returned as empty lists. Requires the caller to have edit permission for the page. Use this endpoint — not the public Wagtail pages API — to read back a draft: the Wagtail pages API returns only live pages and does not expose authoring source or revision IDs.

Preview a draft post:

GET /api/editor/posts/{id}/preview/

Returns the latest editable revision rendered through django-cast’s normal Wagtail preview path. On success the response is the full themed page with Content-Type: text/html; charset=utf-8 rather than a JSON envelope, so a client can load it directly in a browser or iframe. The endpoint renders the same latest revision that GET, PATCH, and the publish action operate on: an unpublished draft when one exists, otherwise the current live revision.

Errors still use the existing editor JSON envelopes (application/json), for example not_found for a missing post or permission_denied when the caller cannot edit the page. Preview is a read action and requires no token scope, but the caller must still be authenticated, have Wagtail admin access, and have edit permission for the page.

Preview authorization and rendering identity are separate. Post and Episode API previews render as an anonymous visitor, using the configured blog/site theme; the internal render receives neither the caller’s Authorization header nor cookies. A browser session therefore cannot change the rendered identity or theme. Wagtail admin previews retain the editor’s session identity and theme preferences. Preview HTML is marked Cache-Control: private, no-store. API preview query parameters are not forwarded to the internal page render. Cache isolation appends a unique internal query parameter through Wagtail’s private _get_dummy_headers hook, tested against supported Wagtail 7 and 8 versions. Keep the cache-isolation regression in the compatibility matrix when upgrading Wagtail. Any query string supplied by that upstream hook is preserved; the current Wagtail hook does not forward the original request’s query string. The configured SECURE_PROXY_SSL_HEADER is retained as transport metadata, so proxy-terminated HTTPS still reaches the internal renderer as HTTPS.

Previews use the preview body’s media in memory. They do not save pages, revisions, edit logs, media relationships, or Gallery records. Missing image renditions (including cover images and contributor avatars) may be generated as derived cache data; previews do not delete obsolete renditions. Publication still synchronizes persisted media relationships. Custom blocks, middleware, and site overrides must respect this policy too.

Anonymous rendering does not grant access to protected audio or transcript endpoints. Interactive requests still require their own authorization; previews do not embed access tokens or make private media public.

Update a draft post:

PATCH /api/editor/posts/{id}/
Content-Type: application/json

Creates a new Wagtail draft revision for an existing Post. The request must include the latest_revision_id returned by GET or the previous write response as base_revision_id or as an If-Match request header. Omitted fields are left unchanged; a provided overview or detail replaces that whole section, including when the supplied value is an empty list. Omitted sections and unsupported/custom top-level body sections are preserved. At least one mutable field besides the revision token is required; publish: false does not count as a mutable field and publish: true is rejected as unsupported.

Update fields:

  • base_revision_id (optional when If-Match is supplied): optimistic concurrency token.

  • require_unpublished (optional, default false): when true, reject the update with 409 published_post if the page is live or 409 scheduled_post if any Wagtail revision is approved for scheduled publication. The live/scheduled-state check and revision-token check run under the same transactional page-row lock as the new revision; all existing page revisions are locked before their schedule fields are inspected. A concurrent publish or schedule cannot slip between validation and the draft write.

  • title (optional): page title.

  • slug (optional): URL slug; both the persisted page-row and latest-draft slug namespaces must remain unique under the same parent. For an unpublished page, PATCH updates the persisted row slug atomically with the new revision; failure rolls back both. A live page retains its public row slug until the rename revision is published.

  • seo_title (optional): Wagtail Promote-tab title; send "" to clear it.

  • search_description (optional): Wagtail Promote-tab description; send "" to clear it.

  • visible_date (optional): ISO 8601 datetime string.

  • cover_image (optional): {"id": <image id>, "alt_text": "…"}, or null to clear the draft cover image.

  • tags (optional): full replacement list of tag name strings.

  • categories (optional): full replacement list of PostCategory IDs.

  • overview (optional): full replacement ordered list of body blocks.

  • overview_markdown (optional): full replacement as Markdown; mutually exclusive with overview (see Markdown input).

  • detail (optional): full replacement ordered list of body blocks.

  • detail_markdown (optional): full replacement as Markdown; mutually exclusive with detail.

  • publish (optional): must be false or absent.

Example update request:

{
  "base_revision_id": 6543,
  "require_unpublished": true,
  "title": "Updated draft title",
  "seo_title": "Updated search title",
  "search_description": "A concise updated summary.",
  "overview": [
    {"type": "paragraph", "value": "<p>Updated draft text.</p>"}
  ]
}

Automation that is allowed to edit drafts but must never write after publication or scheduling should always send require_unpublished: true. A page that is already live is left unchanged and returns:

{
  "code": "published_post",
  "detail": "This post is already live; the requested draft-only update was refused."
}

A scheduled page is likewise left unchanged and returns 409 with code scheduled_post. Post and episode responses expose this state as status: "scheduled" even when live is false.

Post and Episode PATCH also honor Wagtail editorial locks. If page.get_lock().for_user(user) applies, the request returns 409:

{"code": "page_locked", "detail": "This page is locked for editing."}

No revision, slug change, or edit log is saved for a rejected write. A basic lock normally allows its owner to edit; global edit locks and scheduled publication locks apply to their owners too. These checks apply even when require_unpublished is false. Permission checks, revision conflicts, and the explicit draft-only precondition take precedence, so a scheduled page with require_unpublished: true still returns scheduled_post.

Each successful PATCH records one wagtail.edit audit entry with the acting user and new revision. The log and revision are saved in the same transaction. This applies on both Wagtail 7 and 8. Publication has its own, stricter lock and workflow policy below.

Instead of putting the token in the JSON body, clients may send the same revision id as a strict If-Match header:

If-Match: "6543"

Only a single strong quoted integer token is accepted; optional surrounding whitespace is ignored. Bare integers (If-Match: 6543), weak ETags (W/"6543"), wildcard *, blank values, and comma-separated ETag lists are rejected with the normal validation_error envelope. If both base_revision_id and If-Match are supplied, they must carry the same revision id. This endpoint uses If-Match only as an alternate transport for django-cast’s revision token: malformed or contradictory tokens return 400 validation_error and stale valid tokens return 409 revision_conflict. It does not emit response ETag headers in this API version, and it intentionally does not use 412 Precondition Failed.

A stale base revision returns 409 Conflict:

{
  "code": "revision_conflict",
  "detail": "The page has a newer revision than the submitted base revision.",
  "current_revision_id": 6550,
  "submitted_base_revision_id": 6543,
  "edit_url": "/admin/pages/987/edit/"
}

Publish a draft post:

POST /api/editor/posts/{id}/publish/

Publishes the latest draft revision for an existing Post through Wagtail’s revision publishing path. The caller must be authenticated, have Wagtail admin access, be able to edit the target page through the editor API, and have Wagtail publish permission for that page. The action does not accept a request body and does not use base_revision_id in the request body. Clients may optionally send If-Match: "<latest_revision_id>" to bind the approval to the revision they reviewed. The header uses the same strict quoted-integer syntax and validation_error response documented for PATCH above. A stale token returns the same 409 revision_conflict envelope, and the newer revision is not published. When the header is omitted, the endpoint remains backwards compatible and publishes the latest revision found while holding the page-row lock.

Publication honors editorial locks for both Posts and Episodes, including Episodes addressed through the Post endpoint:

  • An ordinary lock allows its owner to publish, without removing the lock. A lock owned by someone else (or with no owner) rejects publication, even for superusers. With WAGTAILADMIN_GLOBAL_EDIT_LOCK=True, a locked page also rejects its owner’s publication; unlocked pages are unaffected.

  • Any approved scheduled revision blocks API publication, even when a newer draft exists. Cancel or change the schedule explicitly in Wagtail admin.

  • An active workflow, including needs_changes, blocks every API caller, including reviewers and superusers. Complete or explicitly cancel it in Wagtail first. Merely assigning a workflow without starting it does not block publication. This follows Wagtail’s workflow-enabled setting.

  • Other/custom locks are honored when page.get_lock().for_user(user) is true.

Lock refusals return 409 with {"code": "page_locked", "detail": "This page is locked for publication."}. Active workflows return 409 workflow_active with guidance to complete or cancel the workflow. Existing permission, revision-conflict, no-unpublished-draft and no-revision checks retain precedence. After those checks, approved schedules take precedence over active workflows, which take precedence over other locks. Episode audio validation follows these publication guards, so a locked Episode without audio returns the lock/workflow error rather than an audio error. Refusal leaves page content, revisions, schedules, workflows, media links and audit entries unchanged. There is no automatic unlock, workflow cancellation, or force-publish option.

The workflow rule is deliberately stricter than Wagtail admin: permission to edit during review is not approval to bypass the workflow. It applies regardless of WAGTAIL_WORKFLOW_CANCEL_ON_PUBLISH. These guards are specific to the editor API; normal admin publication, workflow completion and scheduled publication retain Wagtail’s behavior. Optional If-Match semantics are unchanged.

The API checks persisted state inside its publication transaction while holding the page and existing revision row locks. PostgreSQL regression tests exercise concurrent ordinary locking, schedule approval and stock group-approval workflow submission. This is not a universal serialization contract for custom workflow tasks or external writers that use different persistence paths.

The success response is the normal editor post shape plus publish metadata:

{
  "id": 987,
  "type": "cast.Post",
  "title": "Weeknotes 2026-25",
  "slug": "weeknotes-2026-25",
  "page_slug": "weeknotes-2026-25",
  "seo_title": "Weeknotes 2026-25",
  "search_description": "A concise summary of this week's main theme.",
  "parent": {"id": 123},
  "visible_date": "2026-06-19T18:00:00+02:00",
  "tags": ["weeknotes"],
  "categories": [],
  "cover_image": null,
  "overview": [
    {"type": "paragraph", "value": "<p>Ready.</p>"}
  ],
  "detail": [],
  "latest_revision_id": 6543,
  "previous_revision_id": null,
  "live": true,
  "status": "live",
  "preview_url": "/admin/pages/987/view_draft/",
  "edit_url": "/admin/pages/987/edit/",
  "api_url": "/api/editor/posts/987/",
  "published_revision_id": 6543,
  "public_url": "/blog/weeknotes-2026-25/"
}

Publishing a live page that has unpublished draft changes publishes the latest draft revision. Publishing a page that is already live with no unpublished draft returns 409 Conflict:

{
  "code": "no_unpublished_draft",
  "detail": "This post is already live and has no unpublished draft revision."
}

Permission failures use the standard permission_denied envelope. Missing posts use the standard not_found envelope.

Episode endpoints

Podcast episodes have dedicated draft create/read/update endpoints that mirror the post endpoints. Episode is a Post subclass, so the body (overview/detail), tags, categories, cover_image, visible_date, slug, revision/base_revision_id conflict detection, the atomic require_unpublished precondition, the draft-only publish guard, and the structured error envelopes all behave exactly as documented for posts above. The endpoints are:

POST  /api/editor/episodes/
GET   /api/editor/episodes/{id}/
PATCH /api/editor/episodes/{id}/
POST  /api/editor/episodes/{id}/publish/
GET   /api/editor/episodes/{id}/preview/

The parent must be a cast.Podcast; a cast.Blog or any other parent is rejected with a validation_error on parent. These endpoints serve episodes only — a non-episode page id returns the not_found envelope.

Create and update stay draft-only and reject publish: true like posts; publishing happens through the explicit episode publish action below.

Beyond the shared post fields, episodes accept and return these episode-specific fields:

  • podcast_audio (optional): {"id": <audio id>} referencing a cast.Audio the caller may choose, or null on PATCH to clear it. Missing or inaccessible audio collapses to the neutral not_found shape at podcast_audio.id. It is optional on a draft but required to publish (see the publish action below).

  • episode_number (optional): positive integer, or null on PATCH to clear it.

  • episode_type (optional): one of full, trailer, bonus, or an empty string (which omits the feed tag, equivalent to full).

  • season (optional): {"id": <season id>} referencing a cast.Season that must belong to the parent podcast, or null on PATCH to clear it. A foreign-podcast season is a validation_error on season.

  • keywords (optional): iTunes keyword string.

  • explicit (optional): one of 1 (yes), 2 (no), 3 (clean); defaults to 1.

  • block (optional): boolean; blocks the episode from iTunes.

Example create request:

{
  "parent": {"id": 7},
  "title": "Episode 12",
  "slug": "episode-12",
  "tags": ["interview"],
  "overview": [
    {"type": "paragraph", "value": "<h2>Show notes</h2>"}
  ],
  "podcast_audio": {"id": 321},
  "episode_number": 12,
  "episode_type": "full",
  "season": {"id": 3},
  "explicit": 1,
  "block": false
}

The response is the standard editor draft shape (id, type of cast.Episode, the shared post fields, preview_url/edit_url, and an api_url of /api/editor/episodes/{id}/) plus the episode-specific fields above, including the page-local previous_revision_id recovery evidence. PATCH requires the same revision token, preserves omitted fields and sections, and keeps the empty-update guard, exactly like posts. As with posts, the token may be sent as base_revision_id in the JSON body or as If-Match: "<latest_revision_id>", with the same strict syntax and validation behavior. parent is immutable.

The episode publish action mirrors the post publish action:

POST /api/editor/episodes/{id}/publish/

It publishes the latest draft revision through Wagtail’s revision publishing path, requires Wagtail admin access plus publish permission for the page, takes no request body, and returns the editor episode shape plus published_revision_id and public_url. Like the post action, it accepts an optional If-Match: "<latest_revision_id>" header and returns the shared revision_conflict envelope for a stale token. Publishing an episode that is already live with no unpublished draft returns the no_unpublished_draft conflict, just like posts.

Publishing requires a non-null podcast_audio (the same rule the Wagtail admin enforces). A publish request for an episode without podcast_audio is rejected with a validation_error envelope (required at podcast_audio) and the episode stays unpublished. Because an Episode is a Post, POST /api/editor/posts/{id}/publish/ also enforces this gate when its id resolves to an episode, so the audio requirement cannot be bypassed through the post publish endpoint.

The episode preview endpoint mirrors post preview: it returns the latest editable episode revision as full themed text/html on success, uses the editor JSON error envelopes on failure, requires edit permission, and requires no token scope. Passing a plain post id to the episode preview endpoint returns the standard not_found envelope.

Error envelopes

Validation errors (400 Bad Request):

{
  "code": "validation_error",
  "errors": {
    "title": [{"code": "required", "message": "This field is required."}],
    "overview.3.value.1.id": [
      {"code": "not_found", "message": "Image 789 does not exist."}
    ]
  }
}

Field paths in errors follow dot notation into the request body so that clients can locate and repair individual fields without guessing.

Permission errors (403 Forbidden):

{"code": "permission_denied", "detail": "…"}

Permission denials tied to a parent page, such as create-under-page checks, may also include parent_id:

{"code": "permission_denied", "detail": "…", "parent_id": 123}

Missing posts (404 Not Found):

{"code": "not_found", "detail": "Post not found."}

Whole-request editor media failures use flat error bodies. Current media upload codes include no_upload_collection (403), post_save_permission_denied (403), probe_timeout (422), probe_failed (422), rate_limited (429), and cleanup_failed (500). Form and field problems use the validation envelope; non_field_errors is the reserved key for request-wide validation errors.

Editor media endpoints

Editor media endpoints use the shared pagination envelope and accept only their documented query parameters. Unsupported parameters, such as tags= instead of repeatable tag=, return validation_error with unsupported_parameter.

List selectable media:

GET /api/editor/media/images/?q=desk&tag=weeknotes
GET /api/editor/media/audios/?q=episode&tag=weeknotes
GET /api/editor/media/videos/?q=demo&tag=weeknotes

The list endpoints return only objects the caller may choose. q is a deterministic database filter: image and video titles are searched with icontains; audio title and subtitle are searched with icontains. Repeatable tag filters match exact stored tag names and are ANDed. Results are ordered newest first (images by created_at, audio/video by created), then by descending ID. Media file fields use relative URLs; pagination links may be absolute. Relative media URLs assume the application serves or proxies media from the same origin; deployments whose storage requires absolute signed CDN URLs should provide a same-origin media path for editor API consumers.

Image result shape:

{
  "id": 456,
  "type": "wagtailimages.Image",
  "title": "Desk photo",
  "file": "/media/original_images/desk.jpg",
  "width": 1600,
  "height": 900,
  "collection": {"id": 1, "name": "Root"},
  "tags": ["weeknotes"],
  "edit_url": "/admin/images/456/"
}

Audio result shape:

{
  "id": 42,
  "type": "cast.Audio",
  "title": "Episode audio",
  "subtitle": "Interview mix",
  "transcript_diarization_mode": "inherit",
  "file_formats": "m4a mp3",
  "mp3": "/media/cast_audio/episode.mp3",
  "m4a": "/media/cast_audio/episode.m4a",
  "oga": null,
  "opus": null,
  "collection": {"id": 1, "name": "Root"},
  "tags": ["weeknotes"],
  "edit_url": "/admin/castaudio/edit/42/"
}

Video result shape:

{
  "id": 43,
  "type": "cast.Video",
  "title": "Demo clip",
  "original": "/media/cast_videos/demo.mp4",
  "poster": "/media/cast_videos/poster/demo.jpg",
  "collection": {"id": 1, "name": "Root"},
  "tags": ["weeknotes"],
  "edit_url": "/admin/castvideo/edit/43/"
}

Upload media:

POST /api/editor/media/images/
POST /api/editor/media/audios/
POST /api/editor/media/videos/
Content-Type: multipart/form-data

Image uploads accept title, file, tags, and optional collection. Audio uploads accept title, subtitle, transcript_diarization_mode, one of m4a/mp3/oga/opus, tags, chaptermarks, and optional collection. transcript_diarization_mode=enabled is rejected in this slice; use inherit or disabled. Video uploads accept title, original, optional poster, tags, and optional collection.

If collection is omitted and exactly one usable upload collection exists, that collection is selected. If none exists, upload returns no_upload_collection. If more than one exists, upload returns a validation error on collection with code ambiguous. A supplied missing or unusable collection returns collection_permission_denied on collection; malformed collection values return invalid.

Audio and video uploads share a one-in-flight lock per authenticated user; a second concurrent audio/video upload returns rate_limited. The lock uses the default Django cache and requires a shared cache backend, such as Redis or Memcached, to be process-wide in multi-worker deployments; Django’s LocMemCache only protects a single process. Configure CAST_EDITOR_MEDIA_UPLOAD_LOCK_SECONDS when deployments need a longer or shorter owner-token TTL. Editor audio duration probing and optional chapter extraction run inside a cumulative request-path budget controlled by CAST_EDITOR_MEDIA_PROBE_SECONDS. Required audio probing timeout returns probe_timeout and the failed object/files are cleaned up. Required audio probing failures such as invalid ffprobe output or an unavailable ffprobe binary return probe_failed and are also cleaned up. Optional chapter extraction failures are logged and save the audio without extracted chapter marks; malformed individual chapter entries are skipped while valid entries are kept. Video poster generation is optional display enrichment; timeout or ffmpeg/ffprobe failure during poster generation does not fail the upload, and upload can succeed with poster: null.

Discover upload collections:

GET /api/editor/media/collections/?type=image
GET /api/editor/media/collections/?type=audio
GET /api/editor/media/collections/?type=video

The type parameter is required and must be one of image, audio, or video. Responses use the same pagination envelope. Items include id, name, display-only breadcrumb, and ancestors from root to parent. Discovery returns candidate upload targets; custom permission policies can still make a saved audio/video object fail the defensive post-save choose check.

Draft-only and Wagtail permissions

Create and update requests are draft-only. Create requests save Wagtail draft revisions, so newly-created pages are returned with live: false and do not appear in the public Wagtail pages API until explicitly published. Updating an already-live page creates an unpublished draft revision and returns status: "draft" while live stays true. Use POST /api/editor/posts/{id}/publish/ to publish the latest draft revision. An approved future publication is returned as status: "scheduled" and is rejected by PATCH when require_unpublished is true.

Authorization uses standard Wagtail page permissions:

  • GET /api/editor/parents/ — lists pages where the caller has add-child permission.

  • GET /api/editor/posts/?parent=…&slug=… — requires edit permission for the exact matching direct-child post.

  • POST /api/editor/posts/ — requires add-child permission on the selected parent.

  • GET /api/editor/posts/{id}/ — requires edit permission for the page.

  • GET /api/editor/posts/{id}/preview/ — requires edit permission for the page and returns rendered HTML on success.

  • PATCH /api/editor/posts/{id}/ — requires edit permission for the page.

  • POST /api/editor/posts/{id}/publish/ — requires edit and publish permission for the page.

  • POST /api/editor/episodes/ — requires add-child permission on the selected Podcast parent.

  • GET /api/editor/episodes/{id}/ — requires edit permission for the episode.

  • GET /api/editor/episodes/{id}/preview/ — requires edit permission for the episode and returns rendered HTML on success.

  • PATCH /api/editor/episodes/{id}/ — requires edit permission for the episode.

  • POST /api/editor/episodes/{id}/publish/ — requires edit and publish permission for the episode, and a non-null podcast_audio.

Scoped-token authorization

When a request is authenticated by a token that carries OAuth/IndieAuth-style scopes (a scope space-separated string or a scopes iterable on request.auth), the editor API additionally enforces a per-action scope on top of the Wagtail permission checks above:

  • Read endpoints (all GET) require no scope.

  • Create, update, and media-upload endpoints require the write scope.

  • The publish actions require the publish scope.

Scope is necessary but not sufficient — the Wagtail permission check still applies. A token that lacks the required scope receives 403 with code insufficient_scope.

Session authentication and tokens that carry no scope information are treated as unscoped and fall back to pure Wagtail permissions, so existing session and DRF TokenAuthentication setups are unaffected. The accepted scope strings are configurable via CAST_EDITOR_SCOPES (default: write is satisfied by write, create, or update; publish by publish), letting a site match its token issuer’s vocabulary without code changes. Scopes that mean something narrower in the issuer’s model (for example IndieAuth’s media upload scope) are intentionally not bundled into write by default; add them via this setting if your deployment wants them to grant editor write access.

Pagination

List endpoints support pagination:

  • Default page size: 40

  • Maximum page size: 200

  • Query parameters: page, pageSize

Example:

GET /api/videos/?page=2&pageSize=20

Response format:

{
    "count": 100,
    "next": "http://example.com/api/videos/?page=3",
    "previous": "http://example.com/api/videos/?page=1",
    "results": [...]
}

File Uploads

The file upload endpoint accepts multipart/form-data:

const formData = new FormData();
formData.append('original', fileInput.files[0]);

fetch('/api/upload_video/', {
    method: 'POST',
    body: formData,
    credentials: 'include'  // Include session cookie
});

Unsupported media extensions, content types, container signatures, or files over the configured upload size limit are rejected with 400 Bad Request and field-level JSON errors. Audio uploads are handled by the Wagtail admin and chooser views rather than a public DRF upload endpoint.

Error Handling

The API returns standard HTTP status codes:

  • 200 OK: Success

  • 201 Created: Resource created

  • 400 Bad Request: Invalid request data

  • 401 Unauthorized: Authentication required

  • 403 Forbidden: Permission denied

  • 404 Not Found: Resource not found

  • 500 Internal Server Error: Server error

Error responses include a detail message:

{
    "detail": "Authentication credentials were not provided."
}

Using the API

JavaScript Example

Fetching posts with facets:

async function fetchPosts(categorySlug, page = 1) {
    const response = await fetch(
        `/api/wagtail/pages/?type=cast.Post&child_of=4&use_post_filter=true&category_facets=${categorySlug}&page=${page}`,
        { credentials: 'include' }
    );
    return await response.json();
}

Python Client Example

Using the API from Python with httpx:

import httpx

# Create client for session persistence
with httpx.Client() as client:
    # Request modal facet counts for a specific blog
    response = client.get(
        "https://example.com/api/facet_counts/4/",
        params={
            "mode": "modal",
            "search": "python",
            "tag_facets": "django",
        },
    )
    facet_data = response.json()

Headless CMS Usage

Django Cast can function as a headless CMS by:

  1. Using the Wagtail Pages API to fetch content

  2. Implementing a frontend application (React, Vue, etc.)

  3. Optionally using theme packages like cast-vue

Example Vue.js integration:

// Fetch blog posts
const posts = await fetch('/api/wagtail/pages/?type=cast.Post')
    .then(r => r.json());

// Get facet counts for filtering
const facets = await fetch('/api/facet_counts/1/')
    .then(r => r.json());

Performance Considerations

  • Use field limiting to reduce payload size: ?fields=title,slug,date

  • Implement client-side caching for static content

  • Use pagination for large result sets

  • Facet counts are optimized at the repository level

Security Notes

  • All media endpoints filter by authenticated user

  • CSRF protection is enabled for state-changing operations

  • Media uploads are validated for type and size before metadata extraction

  • Images API includes null byte protection

Gallery entry captions

Editor API gallery references accept an optional plain-text caption of at most 250 characters, for example {"id": 456, "caption": "An example"}. Reads return non-empty captions on the matching entry, including repeated uses of the same image. Omitting the caption writes an empty caption; clients must retain returned captions when updating a section. Explicit null and non-string captions are invalid. Image chooser permissions continue to apply to each reference. Captions are rendered as escaped text.

Both legacy integer-valued and structured stored entries remain readable. Editor API clients use the id/caption format above, independently of the internal stored representation.