Episodes REST API
Endpoints for creating, reading, updating, and deleting episodes, including each episode’s tier availability, and for managing per-show episode type definitions. All endpoints require manage_options and a valid X-WP-Nonce header unless noted.
Episodes
GET /benecaster/v1/shows/{id}/episodes
List episodes for a show. Supports filtering, sorting, and pagination.
Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number |
per_page |
int | 20 | Items per page (max 100) |
status |
string | any |
publish, draft, scheduled, any |
orderby |
string | date |
date, episode_number, title |
order |
string | DESC |
ASC or DESC |
search |
string | — | Search by title or description |
episode_type |
string | — | Filter by type slug (e.g. full, trailer, bonus) |
imported |
bool | — | true to return only feed-sync imports |
Response:
{
"items": [
{
"id": 201,
"show_id": 45,
"title": "Ep 84: Interview with Sarah Chen",
"episode_number": 84,
"season_number": null,
"episode_type": "full",
"status": "publish",
"post_date": "2026-11-03 09:00:00",
"duration": "47:23",
"file_size": 44368896,
"audio_url": "https://buzzsprout.com/ep84.mp3",
"artwork_url": "https://...",
"category_ids": [1, 3],
"availability": {
"premium": "2026-11-03 09:00:00",
"basic": "2026-11-10 09:00:00",
"free": "2026-11-17 09:00:00"
},
"uses_show_defaults": false,
"download_count": 342,
"is_imported": false,
"created_at": "2026-11-01 14:00:00",
"updated_at": "2026-11-03 09:00:00"
}
],
"total": 83,
"pages": 5,
"per_page": 20,
"page": 1
}
post_date, created_at and updated_at are the WordPress post’s own dates, so they are in the site’s time zone, as WordPress stores them.
POST /benecaster/v1/episodes
Create a new episode.
Body:
{
"show_id": 45,
"title": "Ep 84: Interview with Sarah Chen",
"description_rss": "This week I chat with Sarah Chen about...",
"content": "<p>Full show notes here...</p>",
"episode_number": 84,
"season_number": null,
"episode_type": "full",
"explicit": "inherit",
"author": null,
"audio_url": "https://buzzsprout.com/ep84.mp3",
"video_url": null,
"preview_url": null,
"embed_audio": null,
"embed_video": null,
"media_type": "audio",
"duration": "47:23",
"file_size": 44368896,
"mime_type": "audio/mpeg",
"artwork_url": null,
"web_visible_datetime": "2026-11-03T09:00:00",
"availability": {
"premium": "2026-11-03 09:00:00",
"basic": "2026-11-10 09:00:00",
"free": "2026-11-17 09:00:00"
},
"use_show_defaults": false,
"visibility_mode": "inherit",
"tier_visibility": null,
"category_ids": [1, 3],
"references": [],
"guests": [],
"external_links": [],
"post_status": "draft"
}
Response: 201 Created with the full episode object.
Every check (episode type, recorded date, audio variants) runs before anything is written, so a 400 means no episode was created.
Custom field values are not part of the episode API. Read and write them through their field groups with benecaster_get_episode_custom_fields(), benecaster_get_field() and benecaster_update_field(). A request that includes a custom_fields key is accepted and the key is ignored, like any other unknown key.
Field notes:
| Field | Notes |
|---|---|
description_rss |
Plain text or basic HTML — placed in the RSS feed <description> |
content |
Full show notes; rendered on the episode page |
episode_type |
One of the show’s own type slugs (GET /shows/{id}/episode-types, custom types included), or full, trailer or bonus when the show has no types of its own. Anything else is refused with 400 invalid_episode_type before anything is written. On PUT, sending back the episode’s current type unchanged is always accepted, and on a move the type is checked against the new show. |
availability |
Tier slug → the date and time the episode reaches that tier. See Availability below |
use_show_defaults |
Boolean. true works the tier dates out from the show’s default availability. See Following the show’s defaults below |
explicit |
Three-value string enum: inherit, yes, or no. See below |
visibility_mode |
inherit (use show defaults), custom (use tier_visibility), or public |
tier_visibility |
Object of tier slugs to datetime strings; used when visibility_mode is custom |
category_ids |
Array of episode category IDs for this show |
post_status |
draft or publish |
file_size |
Bytes. When the episode’s file size is blank or 0 after the write, Benecaster makes a HEAD request to the file the feed enclosure carries (the same file detect-metadata checks by default) and stores the size it reports, and also mime_type when that is blank and the response names an audio/ or video/ type. The response carries what it found. A stored value is never replaced. A failed request writes nothing and is not repeated for that episode and URL for an hour. PUT works the same way. |
preview_url |
Short clip played to visitors who can’t hear the episode. Sanitised with esc_url_raw. Sending "" clears it; omitting the key or sending null leaves it unchanged, like video_url and the other URL fields. GET returns "" when unset. Web player only — never added to any feed. |
The explicit field
explicit is a three-value string enum — "inherit", "yes", or "no" — on both the request and the response.
"inherit"(the default) uses the show’s Explicit setting."yes"marks this episode explicit regardless of the show."no"marks this episode clean regardless of the show.
Unrecognised values coerce silently to "inherit" rather than erroring, so an unfamiliar future value falls back to the show default instead of leaking into the feed.
The show-level value uses a different enum — clean / yes / explicit — because Apple’s wording distinguishes the last two. The episode side does not mirror it, since Apple reads both as explicit and an episode override only ever needs to say explicit or clean.
GET /benecaster/v1/episodes/{id}
Get a single episode with all metadata.
Response: Full episode object — same shape as items in the list response plus computed fields: feed_urls, source_guid (for imported episodes), source_link (read-only string; the imported item’s RSS <link>, its page on the old host, '' when there is none — not in the list response and not accepted on create/update), is_imported.
references holds each reference exactly as stored — {id, reference_id, label, library_label, url, description, display_label, group_id, display_order}. label is the resolved display label (this episode’s override when set); library_label is the library item’s own label. The array is not passed through the benecaster_episode_references filter, so an add-on that rewrites URLs on the episode page does not change what this returns.
unrecognised_availability lists the episode’s access under a name that is none of the show’s tiers, public, the follower slug or a buy-up’s: usually an episode sold on its own, or a tier or add-on since removed. Each entry is {slug, available_datetime}, in the site’s time zone. These are the entries the editor’s Access tab shows under Other Access, and they are not in the availability map. [] when there are none.
The explicit field is the episode’s own override, not the resolved value. To know what the feed will actually emit, resolve it against the show’s setting — server-side that is EpisodeMeta::get_effective_explicit(); over REST, read the show’s Explicit value and apply inherit yourself.
PUT /benecaster/v1/episodes/{id}
Update an episode. All fields are optional:
- A field sent as
""is cleared. - A field sent as
null, or left out, is left unchanged. - Every check (episode type, recorded date, a move’s conflicting fields, audio variants such as an unknown tier in
invalid_tier_slug/invalid_audio_variants) runs before anything is written, so a400leaves the episode exactly as it was: no title, content,post_statusorpost_datechange is applied.
Field notes:
| Field | Notes |
|---|---|
availability |
Replaces the episode’s tier dates. See Availability below |
use_show_defaults |
Boolean. See Following the show’s defaults below |
references |
Replaces the episode’s references. An entry carrying a reference_id updates that library item — its url, description and, when you send library_label, its label — so the change reaches every episode citing it. The library label is taken from library_label, never from label (which may be a display override). Omitted fields are left alone, not blanked. display_label, group_id and display_order stay per episode. An entry without reference_id finds or creates a library item by label and URL. |
podcast2_transcript_url |
Podcasting 2.0 transcript URL. Emitted as <podcast:transcript url="..." type="..." />; omitted from the feed when empty. |
podcast2_transcript_type |
MIME type for the transcript. One of text/plain, text/html, application/srt, text/vtt, application/json — any other value is dropped on save. |
podcast2_transcript_url_source |
Optional, write-only — not stored as its own field. Declares how podcast2_transcript_url was produced: media_picker or autodetect. Needed because the server can’t otherwise tell a picked or auto-detected URL from a typed one — the same string is reachable either way. Absent, manual, or an unrecognised value means a manual write, and benecaster_transcript_url_generated does not fire for it. |
season_number |
"" or 0 removes the season, so the feed carries no <itunes:season>. |
episode_number |
"" stores 0 (Auto). If the episode is published after the write, the next number is assigned straight away through benecaster_episode_auto_number; a draft stays unnumbered until it is published. |
show_id |
A different show’s ID moves the episode to that show (see Moving an episode below). The episode’s own show ID changes nothing. |
Response: Updated episode object.
Moving an episode
Send another show’s show_id to move the episode there. The move runs before anything else in the request, so the rest of the request applies to the episode on its new show.
- Its availability is cleared. The episode is in no feed of either show until you give it dates on the new show.
- Audio versions are kept only for tiers the new show has.
- An episode type the new show lacks becomes
full. - Episode and season numbers are unchanged.
- References are added to the new show’s reference library.
- Download history stays with the old show.
On success, the response gains a move object describing what happened:
"move": {
"from_show_id": 45,
"to_show_id": 52,
"availability_cleared": true,
"dropped_audio_variants": ["premium"],
"episode_type_reset_from": "interview",
"credit_source_cleared": false,
"references_moved": 3
}
dropped_audio_variants lists the tier slugs whose audio versions were removed. episode_type_reset_from is the old type when it was reset to full, otherwise null. credit_source_cleared is true when the episode’s stamped credit came from a template the new show doesn’t have; the stamped text itself stays. references_moved is how many references moved into the new show’s library.
Errors:
| Status | Code | When |
|---|---|---|
| 400 | episode_move_conflicting_fields |
The request also sends availability, use_show_defaults, required_buyups, audio_variants or references. Move the episode first, then save those in a second request. |
| 404 | rest_not_found |
show_id is not a show. |
| 409 | episode_move_blocked |
Listeners hold access the new show can’t honour. Nothing is written. data.blockers lists each one as {type, label}, where type is required_buyup, buyup_access, standalone_access, or a type an add-on adds. |
Remove each blocker first: a required buy-up or buy-up access on the episode’s Access tab, and a standalone sale through its own product or, if nothing sells it any more, with DELETE /episodes/{id}/unrecognised-availability/{slug} (below).
The move fires benecaster_episode_move_before and benecaster_episode_move_after, and add-ons can add their own blockers with benecaster_episode_move_blockers.
DELETE /benecaster/v1/episodes/{id}/unrecognised-availability/{slug}
Remove one entry from the episode’s unrecognised_availability: the Remove access action under Other Access on the Access tab. It takes effect immediately and clears the show’s feed cache. Listeners who held access under that name lose the episode, so remove one only when nothing sells it any more. It is a route of its own, never a PUT field, so a removal can’t ride along with a save.
Response: 200 with { "removed": true, "unrecognised_availability": [ ... ] }, the entries that remain.
| Status | Code | When |
|---|---|---|
| 400 | availability_row_not_removable |
The slug is one of the show’s tiers, public, the follower slug or a buy-up’s. Change those in their own sections instead. |
| 404 | rest_not_found |
The episode has no entry under that slug. |
DELETE /benecaster/v1/episodes/{id}
Delete an episode and all associated availability records.
Response: 200 OK with { "deleted": true }
POST /benecaster/v1/episodes/{id}/detect-metadata
Detect the duration, file size and format of a media file. Makes a HEAD request for the size and type, then reads the start of the file for the duration. Requires manage_options. It returns what it found and stores nothing; the episode editor’s Detect button puts the values into the form, and they are saved with the episode.
Body:
| Field | Type | Description |
|---|---|---|
url |
string | Optional. The file to probe. When omitted, the file the episode’s feed enclosure carries: the video URL when media_type is video and a video URL is set, otherwise the audio URL, otherwise the video URL. |
Response:
{
"url": "https://media.example.com/episode-42.mp3",
"file_size": 44368896,
"mime_type": "audio/mpeg",
"duration": "47:23",
"duration_seconds": 2843
}
file_size, mime_type, duration and duration_seconds are each null when the file’s response did not give that value.
Errors:
| Code | HTTP | Message |
|---|---|---|
invalid_url |
400 | A valid audio or video URL (http:// or https://) is required. No url was sent and the episode has neither an audio nor a video URL, or the URL does not start with http:// or https://. |
unreachable |
422 | The media file could not be reached. The HEAD request failed or returned a non-2xx status. |
Availability
Availability has no endpoint of its own. It is the availability field on the episode, read from any episode response and written with POST /episodes or PUT /episodes/{id}.
"availability": {
"premium": "2026-11-03 09:00:00",
"basic": "2026-11-10 09:00:00"
}
The times are in the site’s time zone, not UTC. That holds in both directions, because they are the values a podcaster sees and types in the episode editor. Benecaster converts them to UTC when it saves them. Send site-local times: a time already converted to UTC is converted again, and the episode unlocks early or late by the site’s offset. See Dates, Times and Time Zones.
Reading. The map holds one key per tier that has a date. A tier with no date is left out of the map, not returned as null. Buy-ups are left out too. They are set separately, with the required_buyups field, an array of buy-up IDs.
Writing. The map you send is the episode’s whole set of tier dates. Each tier with a date is created or updated. A tier of the show — from its tier mapping, or its built-in membership tiers — that you leave out, or send as null or "", loses its date and leaves that tier’s feed; {} clears every tier. A row that is not one of the show’s tiers, such as the one a required buy-up keeps, is never removed this way. Leave availability out of the request altogether and the dates are not changed. Writing fires the benecaster_availability_datetimes filter before the rows are saved and benecaster_availability_set after.
Following the show’s defaults
Every episode response carries uses_show_defaults — true while the episode’s tier dates follow the show’s default availability.
POST /episodes and PUT /episodes/{id} accept use_show_defaults:
trueworks the tier dates out from the show’s default availability against the episode’s current publish date, and the episode follows the defaults from then on: its dates are worked out again on every save. Anyavailabilitymap in the same request is ignored. On a show with no default availability,trueapplies nothing, and the episode does not follow.falsestops following. So does sending explicit dates, and onPUT, so does sending anyavailabilitymap.- Absent: on
POST, a request with neitheruse_show_defaultsnor any dates follows the defaults. OnPUT, a request with neither changes nothing.
The defaults themselves are the show’s default_availability and default_release_time fields, on GET and PUT /benecaster/v1/shows/{id}:
"default_availability": {
"tiers": {
"premium": { "offset_days": 0 },
"basic": { "offset_days": 7 }
}
},
"default_release_time": "09:00"
offset_days is how many days after release the tier gets a new episode, from 0 to 365; a tier left out never gets one. GET always returns tiers as an object, {} when the show has no defaults. default_release_time is the time of day the unlocks use, as HH:MM; "" clears it, so unlocks use each episode’s own publish time, and any other value is ignored.
Member Thanks
GET /benecaster/v1/episodes/{id}/member-thanks
Returns a list of members to thank for the episode, using the configured query strategy.
Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
query_type |
string | random_cycling |
Query strategy. Built-in: new_since_last_episode, random_cycling, milestone. Additional types via benecaster_member_thanks_query_types |
count |
int | 5 | Number of members to return. 0 returns all results |
tier |
string | — | Filter results to a specific tier slug |
Response:
{
"episode_id": 201,
"show_id": 45,
"query_type": "random_cycling",
"is_accepted_list": false,
"members": [
{
"user_id": 1042,
"display_name": "Sarah Chen",
"tier_slug": "premium",
"joined_at": "2026-09-14T11:32:00"
}
]
}
is_accepted_list is true when the Podcast Workflow add-on is active and a list has been accepted for this episode via the Episode Workbench — in that case the accepted list is returned regardless of query_type and count.
Errors:
| Code | HTTP | Condition |
|---|---|---|
rest_not_found |
404 | Episode does not exist |
invalid_query_type |
400 | query_type is not a registered strategy |
Episode Types
Episode types are per-show labels that map to iTunes episode types (full, trailer, bonus). The default types (Full Episode, Trailer, Bonus, Sponsored) are created automatically for every new show.
GET /benecaster/v1/shows/{id}/episode-types
Returns all episode type definitions for a show.
Response:
[
{ "slug": "full", "label": "Full Episode", "itunes_type": "full", "sort_order": 0, "is_default": true, "episode_count": 82 },
{ "slug": "trailer", "label": "Trailer", "itunes_type": "trailer", "sort_order": 1, "is_default": true, "episode_count": 1 },
{ "slug": "bonus", "label": "Bonus", "itunes_type": "bonus", "sort_order": 2, "is_default": true, "episode_count": 0 },
{ "slug": "sponsored", "label": "Sponsored", "itunes_type": "full", "sort_order": 3, "is_default": true, "episode_count": 0 }
]
episode_count is used by the episode editor delete/reassign modal to warn when deleting a type that has episodes.
POST /benecaster/v1/shows/{id}/episode-types
Creates a new episode type for a show.
Body:
{
"label": "Sponsored Bonus",
"itunes_type": "full"
}
slug is auto-derived server-side from label. Made unique within the show by appending -2, -3, etc. if needed.
Response: 201 Created with the type object.
Errors:
| Code | HTTP | Condition |
|---|---|---|
invalid_param |
400 | itunes_type not one of full, trailer, bonus |
invalid_param |
400 | label empty or exceeds 255 characters |
PUT /benecaster/v1/shows/{id}/episode-types/{slug}
Updates the label and/or iTunes mapping of an existing type. Slug is immutable — episodes store the slug in _benecaster_episode_type and renaming it would orphan existing episodes.
Body: label and/or itunes_type — all fields optional.
Response: Updated type object.
DELETE /benecaster/v1/shows/{id}/episode-types/{slug}
Deletes an episode type. All episodes using this type are bulk-reassigned to another type in the same transaction.
Required query param: reassign_to — slug of the type to reassign to.
Response:
{
"deleted": "sponsored",
"reassigned_count": 14
}
Errors:
| Code | HTTP | Condition |
|---|---|---|
missing_reassign_to |
400 | reassign_to query param absent |
invalid_reassign_to |
400 | reassign_to slug doesn’t exist on this show |
reassign_to_self |
400 | reassign_to equals the slug being deleted |
last_type |
409 | This is the last remaining type — shows must always have at least one |
rest_not_found |
404 | Slug doesn’t exist on this show |
Example:
DELETE /wp-json/benecaster/v1/shows/45/episode-types/sponsored?reassign_to=full
Feed Sync (Recurring)
POST /benecaster/v1/shows/{id}/sync
Trigger a manual feed sync — imports only new episodes not yet in WordPress (episodes whose GUID is not already stored). Premium only.
Body:
{
"source_feed_url": "https://feeds.buzzsprout.com/12345.rss"
}
Response:
{
"episodes_found": 86,
"episodes_new": 3,
"drafts_created": 3,
"draft_ids": [201, 202, 203],
"log_id": 47
}
For one-time bulk import of all episodes during setup, use POST /shows/{id}/import (see Setup Wizard REST API) instead.
Filters
| Filter | Where it fires |
|---|---|
benecaster_episode_auto_number |
Filters the auto-assigned episode number on first publish |
benecaster_episode_itunes_type |
Filters the resolved iTunes episode type during feed compilation |
benecaster_episode_is_accessible |
Fires inside benecaster_user_can_access_episode() — override episode access for any tier |
benecaster_episode_references |
Filters the references when the episode page renders them. Not applied to REST responses |
See Also
Need this built rather than just documented? See our services →