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 IDdescendant_of: Filter to include all descendants of the page with this IDtranslation_of: Filter to only include translations of the page with this IDfields: Comma-separated list of fields to include in responseorder: Ordering field (prefix with-for descending)slug: Filter by slug (exact match)show_in_menus: Filter pages shown in menussearch: Full-text search (note: when usinguse_post_filter=true, this uses Django Cast’s enhanced search)limit: Number of results per pageoffset: Number of results to skip
Django Cast custom filters (requires use_post_filter=true):
use_post_filter: Set totrueto enable Django Cast’s enhanced filteringdate_facets: Filter by year-month (format:YYYY-MM)category_facets: Filter by category slugtag_facets: Filter by tag slugdate_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_datefor 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}
]
}
}
Modal mode¶
Request:
GET /api/facet_counts/{blog_id}/?mode=modal
Supported modal selection params:
search, date_facets, category_facets, tag_facets, o.
Example:
GET /api/facet_counts/4/?mode=modal&search=python&tag_facets=django&category_facets=til
Modal response shape:
{
"mode": "modal",
"result_count": 1,
"groups": {
"date_facets": {
"selected": "",
"all_count": 1,
"options": [
{"slug": "2026-02", "name": "2026-02", "count": 1},
{"slug": "2026-01", "name": "2026-01", "count": 0}
]
},
"tag_facets": {
"selected": "django",
"all_count": 2,
"options": [
{"slug": "django", "name": "django", "count": 1},
{"slug": "python", "name": "python", "count": 1}
]
},
"category_facets": {
"selected": "til",
"all_count": 2,
"options": [
{"slug": "til", "name": "Today I Learned", "count": 1},
{"slug": "weeknotes", "name": "WeekNotes", "count": 1}
]
}
}
}
Notes:
Only
mode=modalenables modal responses; unknown modes fall back to legacy output.Group keys are limited to configured facet groups from
CAST_FILTERSET_FACETSintersected with:date_facets,tag_facets,category_facets.Legacy mode uses conjunctive counts (fully filtered result set).
Modal mode uses disjunctive per-group counts (temporarily excludes the group being counted).
optionsinclude zero-count values so modal UIs can keep disabled choices visible.ois accepted for URL-state parity but does not change modal counts.date_after/date_beforeare part of the list filterset, but are not currently applied in modal mode.
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.
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 aBlogorPodcastthe caller may add to.title(required): page title.slug(optional): URL slug; auto-derived fromtitleif 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 ofPostCategoryIDs.overview(required unlessoverview_markdownis sent): ordered list of body blocks (see below). Send[]when creating a post that only populatesdetail.overview_markdown(optional): Markdown alternative tooverview; see Markdown input.detail(optional): ordered list of body blocks.detail_markdown(optional): Markdown alternative todetail.publish(optional): must befalseor 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
paragraphblock; each top-level fenced or indented code block becomes acodeblock. The first word of the fence info string is the language, ortextwhen 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 mostdata: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
paragraphblocks, 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 (
) are rejected with codeinline_image. The message names the source line, or line range, of each paragraph or heading that contains images. Use structuredimage/galleryblocks so media permission checks apply.Sending both
overviewandoverview_markdown(or bothdetailanddetail_markdown) returns400 validation_errorwith codeconflictat 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.sourcefor an empty fenced code block.A Markdown section replaces the whole section on PATCH, like a block list. It cannot carry
unsupportedplaceholders, 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 whenIf-Matchis supplied): optimistic concurrency token.require_unpublished(optional, defaultfalse): whentrue, reject the update with409 published_postif the page is live or409 scheduled_postif 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": "…"}, ornullto clear the draft cover image.tags(optional): full replacement list of tag name strings.categories(optional): full replacement list ofPostCategoryIDs.overview(optional): full replacement ordered list of body blocks.overview_markdown(optional): full replacement as Markdown; mutually exclusive withoverview(see Markdown input).detail(optional): full replacement ordered list of body blocks.detail_markdown(optional): full replacement as Markdown; mutually exclusive withdetail.publish(optional): must befalseor 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 acast.Audiothe caller may choose, ornullonPATCHto clear it. Missing or inaccessible audio collapses to the neutralnot_foundshape atpodcast_audio.id. It is optional on a draft but required to publish (see the publish action below).episode_number(optional): positive integer, ornullonPATCHto clear it.episode_type(optional): one offull,trailer,bonus, or an empty string (which omits the feed tag, equivalent tofull).season(optional):{"id": <season id>}referencing acast.Seasonthat must belong to the parent podcast, ornullonPATCHto clear it. A foreign-podcast season is avalidation_erroronseason.keywords(optional): iTunes keyword string.explicit(optional): one of1(yes),2(no),3(clean); defaults to1.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 selectedPodcastparent.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-nullpodcast_audio.
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: Success201 Created: Resource created400 Bad Request: Invalid request data401 Unauthorized: Authentication required403 Forbidden: Permission denied404 Not Found: Resource not found500 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:
Using the Wagtail Pages API to fetch content
Implementing a frontend application (React, Vue, etc.)
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,dateImplement 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.
Comment Moderation¶
Export training data for spam filter:
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.