Episodes
Episode data and transcripts
GET /episodes/{slug}/{episode_id}/chapter-images/{filename}
Section titled “GET /episodes/{slug}/{episode_id}/chapter-images/{filename}”Get a local embedded chapter image
Serves extracted JPEG or PNG bytes without a remote fetch. When feed authentication is enabled, requires a valid global or scoped subscriber key. Served chapters JSON includes the supplied key on local image URLs. HEAD is also supported. Responses prevent content sniffing and allow cross-origin podcast players.
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)filename(path, string, required)key(query, string): Required when feed authentication is enabled
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Stored chapter image | image/jpeg, image/png |
| 401 | Missing or invalid feed key | |
| 404 | Missing, invalid or unsupported chapter image |
GET /feeds/{slug}/episodes
Section titled “GET /feeds/{slug}/episodes”List episodes for a feed
Returns paginated list of episodes for a podcast. For the recents feed the rows come from the source feeds (see feedSlug and feedTitle); they are all processed, so a status filter other than processed returns an empty page. Title search applies before pagination. With selection=true, pagination is ignored and selection contains every matching episode’s action state, up to 501 rows (truncated is true past that cap); episodes is empty. Bulk actions still accept at most 500 IDs.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierstatus(query, string, one ofall,discovered,pending,processing,processed,failed,permanently_failed,deferred,filtered): Filter by episode status.filteredlists the episodes the feed’s title, description, and duration filters match.limit(query, integer): Maximum number of episodes to returnoffset(query, integer): Number of episodes to skipsort_by(query, string, one ofpublished_at,created_at,episode_number,title,status): Field to sort episodes bysort_dir(query, string, one ofasc,desc): Sort directionsearch(query, string): Case-insensitive literal title substring, trimmed before matching. Searches the full feed, including Recents; wildcard characters are literal.selection(query, boolean): Return matching action-state rows in selection and an empty episodes array, capped at 501 rows (see truncated). Ignores limit and offset for selecting rows; limit still reports the normal capped request value and offset is 0.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | List of episodes | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodes(array of EpisodeSummary)selection(array of EpisodeSelection): Present only when selection=true; every matching episode without pagination, up to 501 rows.total(integer)limit(integer)offset(integer)truncated(boolean): Present only when selection=true; true when more than 501 episodes matched and selection was capped.
GET /feeds/{slug}/episodes/{episode_id}
Section titled “GET /feeds/{slug}/episodes/{episode_id}”Get episode details
Returns detailed information about an episode including ad markers
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Episode details | EpisodeDetail |
| 404 | Resource not found | Error |
GET /feeds/{slug}/episodes/{episode_id}/artwork
Section titled “GET /feeds/{slug}/episodes/{episode_id}/artwork”Get episode artwork
Returns the episode’s cover, fetching and caching it from the publisher on first request. Publishers block images requested with a cross-site Referer, so the web UI asks here instead of loading them from the publisher directly. The URL comes from the episode record, never from the caller. Redirects to the feed cover when the episode has no cover of its own or the publisher refuses the fetch.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Artwork image | image/jpeg, image/png |
| 302 | No episode cover available; redirects to the feed cover | |
| 400 | Bad request - invalid input | Error |
GET /feeds/{slug}/episodes/{episode_id}/transcript
Section titled “GET /feeds/{slug}/episodes/{episode_id}/transcript”Get episode transcript
Returns the full transcript text for an episode
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Episode transcript | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodeId(string)transcript(string)
GET /feeds/{slug}/episodes/{episode_id}/original-transcript
Section titled “GET /feeds/{slug}/episodes/{episode_id}/original-transcript”Get original (pre-cut) transcript
Returns the original transcript text captured before ad segments were removed. Only available for episodes processed with v1.0.51+.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Original transcript | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodeId(string)originalTranscript(string)
GET /feeds/{slug}/episodes/{episode_id}/original-segments
Section titled “GET /feeds/{slug}/episodes/{episode_id}/original-segments”Get original (pre-cut) transcript segments
Returns the original transcript segments with timestamps, captured before any ads were removed. Only available for episodes processed with v2.0.26+.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Original segments | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodeId(string)segments(array of object)segments[].start(number)segments[].end(number)segments[].text(string)
GET /feeds/{slug}/episodes/{episode_id}/final-segments
Section titled “GET /feeds/{slug}/episodes/{episode_id}/final-segments”Get final (post-cut) segments
Returns the post-cut transcript segments with adjusted timestamps after ad removal. Only available for episodes processed with v2.0.26+.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Final segments | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodeId(string)segments(array of object)segments[].start(number)segments[].end(number)segments[].text(string)
GET /feeds/{slug}/episodes/{episode_id}/runs/{run_number}/log
Section titled “GET /feeds/{slug}/episodes/{episode_id}/runs/{run_number}/log”Pipeline log for one processing run
Returns the captured pipeline log for a single processing run, either as parsed JSONL lines or as the raw file. Logs are stored when the feed opts in and the retention setting is above zero; the 404 body distinguishes a run that stored no log from one whose log the retention sweep removed.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.run_number(path, integer, required): Run number, matching processingRuns[].runNumberformat(query, string, one ofjson,raw): json (default) or raw for the JSONL file as a downloadlevel(query, string, one ofdebug,info,warning,error,critical): Minimum level to include; json format only. Levels the server does not know rank above every filter, so they are always returned.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Run log | object |
| 400 | Bad request - invalid input | Error |
| 404 | No log for this run | object |
200 response body
Section titled “200 response body”runNumber(integer)truncated(boolean): True when the run hit the size capbytes(integer)lines(array of object)lines[].ts(string (date-time))lines[].level(string)lines[].logger(string)lines[].msg(string)
404 response body
Section titled “404 response body”error(string)status(integer)code(string, one oflog_not_stored,log_pruned)
GET /feeds/{slug}/episodes/{episode_id}/peaks
Section titled “GET /feeds/{slug}/episodes/{episode_id}/peaks”Waveform peaks for an episode window
Returns ffmpeg-derived waveform peaks for the requested window of an episode’s retained original audio. Drives the wavesurfer.js view in the ad editor. The server auto-coarsens the resolution for very long windows so the JSON payload stays bounded; the response echoes the effective resolution actually used.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.start(query, number): Window start in seconds (default 0)end(query, number): Window end in seconds (default = full duration)resolution_ms(query, integer): Requested peak bucket width in ms (default 50, range 1-1000)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Peaks payload | object |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodeId(string)start(number)end(number or null)resolutionMs(integer)peaks(array of number)
GET /feeds/{slug}/episodes/{episode_id}/transcript-span
Section titled “GET /feeds/{slug}/episodes/{episode_id}/transcript-span”Transcript text for a [start, end] window
Returns the transcript text that spans the requested window of an episode. Used by the AdEditor create mode to auto-populate the text_template field when the user marks a new ad.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.start(query, number, required)end(query, number, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Transcript span | object |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodeId(string)start(number)end(number)text(string)
GET /feeds/{slug}/episodes/{episode_id}/split-candidates
Section titled “GET /feeds/{slug}/episodes/{episode_id}/split-candidates”Proposed divider points for a multi-sponsor ad marker
Finds ad-transition phrases in the transcript spanning the requested window and maps each match back to the start of the transcript segment it falls in, so the ad editor opens with dividers where a split would actually cut (issue #563).
A window with no transition phrase returns no candidates and a single piece. That is a valid answer, not an error: the block may genuinely be one ad, and the user can still add a divider by hand.
Candidates that would leave a piece shorter than the minimum ad duration are omitted, so the editor never opens in an invalid state.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.start(query, number, required)end(query, number, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Divider candidates and the pieces they would produce | object |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”episodeId(string)start(number)end(number)candidates(array of object)candidates[].time(number)candidates[].phrase(string): The transition phrase that produced this candidatepieces(array of object)pieces[].start(number)pieces[].end(number)pieces[].text(string)pieces[].sponsor(string or null)
POST /episodes/{slug}/{episode_id}/reprocess
Section titled “POST /episodes/{slug}/{episode_id}/reprocess”Force reprocess an episode
Queues an episode for reprocessing. Full and default reprocesses retain the saved transcript until fresh transcription begins. Supports four modes:
reprocess(default): Uses learned patterns from the pattern database plus AI analysisfull: Skips the pattern database entirely for a fresh AI-only analysisllm: Re-runs ad detection and re-cut using the SAVED transcript, skipping re-transcription. Returns 400 if no transcript exists.
Reprocessed episodes are prioritized in the processing queue.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Request body (application/json)
Section titled “Request body (application/json)”mode(string, one ofreprocess,full,llm,recut): Processing mode: - reprocess: Use pattern DB + AI (leverages learned patterns) - full: AI only, skip pattern DB (fresh analysis) - llm: Re-detect + re-cut using the saved transcript (skips re-transcription); requires an existing transcript - recut: Re-cut the retained original from the current ad detections (applying edits) and re-time the saved transcript; no transcription or AI. Requires the retained original audio, saved segments, and existing ad markers Defaultreprocess.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 202 | Reprocessing started, or queued behind the active run | object |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
| 409 | Episode is already processing | Error plus object |
| 500 | Internal server error | Error |
202 response body
Section titled “202 response body”message(string)episodeId(string)mode(string)status(string, one ofprocessing,queued)reason(string): Present when status is queued; why it could not start immediately.jobState(string, one ofqueued,processing): Authoritative queue/run state, derived the same way as the episode’s jobState field, so the client reconciles from one field.
409 response body
Section titled “409 response body”Includes every field of Error.
jobState(string, one ofprocessing)
POST /feeds/{slug}/episodes/{episode_id}/regenerate-chapters
Section titled “POST /feeds/{slug}/episodes/{episode_id}/regenerate-chapters”Regenerate chapters from transcript
Regenerates chapters for an episode using the existing VTT transcript. Uses AI topic detection to identify natural chapter boundaries without requiring full reprocessing. The VTT transcript must already exist. The new chapters are also embedded into the processed MP3 as ID3 frames so players that ignore podcast:chapters pick them up. The work runs in the background because the topic pass can outlast a reverse proxy’s request timeout; the episode reports progress. It starts on demand outside the processing queue. Each finished attempt adds a processing-history entry with its own usage and a saved episode log when log storage is enabled, including failed attempts.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 202 | Regeneration started in the background. Poll the episode; chaptersRegenerating clears when it finishes and chaptersRegenError carries a failure. | object |
| 400 | No VTT transcript available (full reprocess required) | Error |
| 404 | Resource not found | Error |
| 409 | A regeneration is already running, the episode is being processed, or an LLM provider rate-limit hold is active | |
| 500 | Internal server error | Error |
202 response body
Section titled “202 response body”message(string)episodeId(string)status(string, one ofstarted)
GET /feeds/{slug}/episodes/{episode_id}/sponsorblock/candidates
Section titled “GET /feeds/{slug}/episodes/{episode_id}/sponsorblock/candidates”List segments reviewable for SponsorBlock
Lists the cut sponsor and self promotion segments of a YouTube episode that carry evidence beyond the model, with any recorded submission outcome. Segments already submitted are returned so they can be shown as sent. allowed is false, with a reason, when submission is not possible.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Candidate segments | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”allowed(boolean, required)reason(string or null, one ofnot_youtube,not_completed,disabled,no_user_id,community_marks,no_video_id,marks_unverified,service_down,null, required): service_down means SponsorBlock failed its last availability probe.segments(array of object, required)segments[].start(number, required)segments[].end(number, required)segments[].category(string or null, required)segments[].sbCategory(string, one ofsponsor,selfpromo, required)segments[].evidence(array of string, required)segments[].submission(one of SponsorBlockSubmission, null, required)
POST /feeds/{slug}/episodes/{episode_id}/sponsorblock/submit
Section titled “POST /feeds/{slug}/episodes/{episode_id}/sponsorblock/submit”Submit reviewed segments to SponsorBlock
Sends the segments a person selected to SponsorBlock. SponsorBlock forbids automated submissions, so this is the only way MinusPod sends anything. Each segment must match a candidate within 0.05 seconds and must not already be submitted. Outcomes are recorded on the episode. A duplicate or rejected result is a recorded outcome and returns 200. Rate limited to 10 per minute.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Request body (application/json, required)
Section titled “Request body (application/json, required)”segments(array of object, required)segments[].start(number, required)segments[].end(number, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Recorded outcomes, one per segment | SponsorBlockSubmitResult |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
| 429 | SponsorBlock rate limited the submission, or the endpoint limit was reached. The outcome is recorded. | SponsorBlockSubmitResult |
| 502 | The request to SponsorBlock failed. The outcome is recorded. | SponsorBlockSubmitResult |
PUT /feeds/{slug}/episodes/{episode_id}/rating
Section titled “PUT /feeds/{slug}/episodes/{episode_id}/rating”Rate an episode
Stores a 1 to 5 quality rating. Notes are trimmed, limited to 2000 characters and kept only below 5. A rating of 5 confirms every cut ad segment through the same path as a confirm correction, without changing the audio or queuing a recut. A 5 also dismisses every held segment without recording a correction.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Request body (application/json, required)
Section titled “Request body (application/json, required)”rating(integer, required)notes(string or null)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | The stored rating, how many cut segments were newly confirmed and how many held segments were dismissed | object |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”rating(EpisodeRating, required)confirmedMarkers(integer, required)dismissedMarkers(integer, required)
DELETE /feeds/{slug}/episodes/{episode_id}/rating
Section titled “DELETE /feeds/{slug}/episodes/{episode_id}/rating”Clear an episode rating
Clears the rating, notes and rating time. Confirmations already recorded stay.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | The rating is now null | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”rating(null)
POST /feeds/{slug}/episodes/{episode_id}/retry-ad-detection
Section titled “POST /feeds/{slug}/episodes/{episode_id}/retry-ad-detection”Retry ad detection
Re-runs ad detection using the existing transcript. Faster than full reprocess since it skips transcription. Use when ad detection failed due to API errors or when you want to try with updated prompts.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Ad detection retry completed | object |
| 400 | Episode has no transcript (must use reprocess instead) | Error |
| 404 | Resource not found | Error |
| 500 | Internal server error | Error |
200 response body
Section titled “200 response body”message(string)adsDetected(integer)status(string)
GET /feeds/{slug}/episodes/{episode_id}/original.mp3
Section titled “GET /feeds/{slug}/episodes/{episode_id}/original.mp3”Stream the pre-cut (original) audio
Returns the untouched audio that was downloaded before ad removal. Used by the Ad Editor’s Review mode so users can hear what was cut. 404 when the episode has no original retained (feature disabled in settings, pre-existing episode processed before the feature, or retention expired). Supports Range requests.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.download(query, string, one of1): When1, the file is sent as an attachment named after the episode title (<Title>-original.mp3or<Title>-cut.mp3) instead of streamed inline.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Original audio stream | audio/mpeg |
| 404 | Resource not found | Error |
GET /feeds/{slug}/episodes/{episode_id}/processed.mp3
Section titled “GET /feeds/{slug}/episodes/{episode_id}/processed.mp3”Stream the current cut (ad-free) audio
Returns the current processed version of the episode under the session auth the UI already has. The public feed route serves the same file but needs a feed key when authenticated feeds are on. 404 when the episode has not been processed or the file is missing. Supports Range requests.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.download(query, string, one of1): When1, the file is sent as an attachment named after the episode title (<Title>-original.mp3or<Title>-cut.mp3) instead of streamed inline.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Processed audio stream | audio/mpeg |
| 404 | Resource not found | Error |
GET /episodes/pending-recuts
Section titled “GET /episodes/pending-recuts”Episodes with review decisions not yet cut into the audio
Review is bulk work, so a correction that changes the audio stamps its episode instead of re-cutting it. An episode collecting several decisions is listed once and cut once.
Parameters
Section titled “Parameters”slug(query, string): Limit the list to one feed’s episodes.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Pending episodes, oldest stamp first | object |
200 response body
Section titled “200 response body”count(integer)episodes(array of object)episodes[].slug(string)episodes[].episodeId(string)episodes[].title(string)episodes[].podcast(string)episodes[].pendingSince(string): When the episode first went stale.episodes[].recutReady(boolean): An apply would queue this row now. False when the retained original audio, saved segments, or markers are missing (the decision then waits for a full reprocess) or while the episode’s own run is queued or underway.episodes[].inFlight(boolean): The episode’s own queued or running pass will apply the decisions itself.
POST /episodes/pending-recuts/apply
Section titled “POST /episodes/pending-recuts/apply”Recut every episode holding unapplied review decisions
Queues one recut per pending episode, using the same recut path and preconditions as the per-feed segment re-render. An episode missing its retained original audio, or whose own run is queued or underway (that run applies the decisions), is skipped and keeps its stamp, so its decisions are not lost.
Request body (application/json)
Section titled “Request body (application/json)”slug(string): Recut only this feed’s pending episodes. Omit to cover every feed.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | How many recuts were queued and how many were skipped | object |
| 429 | Rate limited (5 per minute) |
200 response body
Section titled “200 response body”queued(integer)skipped(integer)
GET /episodes/awaiting-approval
Section titled “GET /episodes/awaiting-approval”Episodes waiting for manual approval
Processed episodes held out of the served feed and audio until approved, oldest first, across feeds.
Parameters
Section titled “Parameters”slug(query, string): Limit the list to one feed’s episodes.limit(query, integer)offset(query, integer)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Awaiting episodes and the total count | object |
200 response body
Section titled “200 response body”count(integer): Total waiting episodes, ignoring limit and offset.episodes(array of object)episodes[].slug(string)episodes[].episodeId(string)episodes[].title(string)episodes[].podcast(string)episodes[].pendingSince(string): When the episode began waiting for approval. Omitted from the held-detections list.episodes[].adCount(integer)episodes[].pendingReviewCount(integer)
POST /episodes/awaiting-approval/approve
Section titled “POST /episodes/awaiting-approval/approve”Approve several waiting episodes
Approves the listed episodes, or every waiting episode of one feed when slug is sent instead of episodes. Each feed’s RSS is rebuilt once. An episode that is not waiting is counted as skipped.
Request body (application/json, required)
Section titled “Request body (application/json, required)”episodes(array of object)episodes[].slug(string, required)episodes[].episodeId(string, required)slug(string): Approve every waiting episode of this feed. Ignored whenepisodesis sent.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | How many episodes were approved and how many were skipped | object |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
| 429 | Rate limited (10 per minute) |
200 response body
Section titled “200 response body”approved(integer)skipped(integer)remaining(integer): Waiting episodes of the feed left after the 500 oldest were approved. 0 for anepisodesrequest.
GET /episodes/held
Section titled “GET /episodes/held”Episodes with detections held for review
Processed episodes with at least one detection held for human review, newest first, across feeds.
Parameters
Section titled “Parameters”limit(query, integer)offset(query, integer)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Held episodes and the total count | object |
200 response body
Section titled “200 response body”count(integer): Total episodes with held detections, ignoring limit and offset.episodes(array of object)episodes[].slug(string)episodes[].episodeId(string)episodes[].title(string)episodes[].podcast(string)episodes[].pendingSince(string): When the episode began waiting for approval. Omitted from the held-detections list.episodes[].adCount(integer)episodes[].pendingReviewCount(integer)
POST /feeds/{slug}/episodes/{episode_id}/approve
Section titled “POST /feeds/{slug}/episodes/{episode_id}/approve”Approve a waiting episode
Clears the approval wait, stamps approvedAt, rebuilds the feed RSS and fires the Episode Processed event.
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | The approved episode | object |
| 404 | Resource not found | Error |
| 409 | The episode is not awaiting approval, or does not exist | Error |
200 response body
Section titled “200 response body”approved(boolean)episode(EpisodeSummary)
POST /episodes/{slug}/{episode_id}/corrections
Section titled “POST /episodes/{slug}/{episode_id}/corrections”Submit ad corrections
Submit corrections for detected ads in an episode. This feedback improves the cross-episode pattern database for better future detection.
Actions:
- confirm: Mark detection as correct (creates/updates pattern)
- reject: Mark as false positive. Stores transcript text for cross-episode matching - similar text in future episodes of the same podcast will be automatically excluded using TF-IDF similarity matching (threshold: 0.75)
- adjust: Correct ad boundaries. Also creates patterns from the adjusted boundaries (like confirm), ensuring accurate pattern text is learned from the corrected segment
- create: Mark a brand-new ad on an episode the detector missed. Top-level start/end/sponsor/text_template required (no original_ad). Creates a new pattern with created_by=‘user’ and a pattern_corrections row of type ‘create’. If the text contains more than one ad transition phrase (a contaminated multi-sponsor read), it is split at those boundaries into one pattern per sponsor segment instead of a single oversized pattern; the response’s patternIds lists every pattern created or reused this way, and pattern_id/sponsor still identify the sponsor-matched primary.
- split: Replace one marker covering back-to-back ads with N
single-sponsor markers (issue #563). Send
original_adplussplit_points(a list of times strictly inside the span) and an optionalpiecesarray of{sponsor}overrides in piece order. Every resulting piece must be at least the minimum ad duration or the request is rejected and the marker is left untouched. A request whose original_ad matches no persisted marker returns 404. Pieces inherit the original’s category, detection stage, confidence, and cut state, so a split never silently changes whether the audio was removed. Correction rows use boundary_adjustment for the first piece and create for the rest: correction_type carries a CHECK constraint SQLite cannot alter in place. Response carries markerCount and patternIds.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifierepisode_id(path, string, required): Episode identifier: a 12-character MD5-hex string for a subscribed feed’s episode, or an sNNeNN id (e.g. s01e05) for a local feed’s.
Request body (application/json, required)
Section titled “Request body (application/json, required)”Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Corrections submitted successfully | object |
| 400 | Bad request - invalid input | Error |
| 404 | Resource not found | Error |
| 409 | Segment is kept for this feed; recategorize instead of confirm, reject, adjust, or split | Error |
200 response body
Section titled “200 response body”message(string): Human-readable result, returned by every typepattern_id(integer): Pattern created or updated; returned by confirm, adjust, and createsponsor(string): Canonical sponsor name; returned by createpatternIds(array of integer): Patterns created or reused; returned by create and splitmarkerCount(integer): Number of markers that replaced the original; returned by splitcategory(string): New segment category; returned by recategorizepreviousCategory(string): Segment category before the change; returned by recategorize
POST /feeds/{slug}/episodes/bulk
Section titled “POST /feeds/{slug}/episodes/bulk”Bulk episode actions
Apply an action to multiple episodes at once. Rate limited to 5/min.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifier
Request body (application/json, required)
Section titled “Request body (application/json, required)”episodeIds(array of string, required)action(string, one ofprocess,reprocess,reprocess_full,reprocess_llm,delete, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Bulk action result | object |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”queued(integer)skipped(integer)freedMb(number)errors(array of string)skippedEpisodes(array of object): Episodes skipped because their state changed or processing already owns them.skippedEpisodes[].episodeId(string, required)skippedEpisodes[].reason(string, required)jobState(string, one ofidle,queued): Present for process/reprocess/reprocess_full/reprocess_llm actions. Bulk enqueues never start processing synchronously, so this is queued whenever anything was queued, else idle. Not present for delete.
POST /feeds/{slug}/episodes/passthrough
Section titled “POST /feeds/{slug}/episodes/passthrough”Set per-episode pass-through override
Set or clear the per-episode pass-through override (issue #746) for one or more episodes. An episode flagged this way is relayed with no transcription, ad detection, or cutting, even when the feed’s own processing mode is not passthrough; the audio may still be transcoded to MP3 when the publisher’s enclosure is not already MP3. Enabling enqueues a normal reprocess for each affected episode so the pass-through branch runs now; disabling only clears the flag. Repeated ids are deduplicated in request order, so each id appears once across accepted and rejected. Rate limited to 5/min.
Parameters
Section titled “Parameters”slug(path, string, required): Podcast feed slug identifier
Request body (application/json, required)
Section titled “Request body (application/json, required)”episodeIds(array of string, required)enabled(boolean, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Pass-through override result | object |
| 400 | Malformed request. Returned when the body is not a JSON object, when episodeIds is missing or is not a non-empty array, when any episodeIds entry is not a non-empty string, when more than 500 ids are sent, or when enabled is missing or not a boolean. | Error |
| 404 | Resource not found | Error |
200 response body
Section titled “200 response body”updated(integer): Number of episodes whose passthroughEnabled flag was setqueued(integer): Episodes that actually hold a queue row after the call, counted from the queue itself rather than from the attempted inserts, so a swallowed enqueue failure is never reported as queued work. Always 0 when disabling.accepted(array of string): Episode ids the override was applied to, in request order.rejected(array of object): Episode ids the override was not applied to, each with its reason.rejected[].episodeId(string, required)rejected[].reason(string, one ofnot_found,processing, required): not_found means no episode with that id belongs to this feed. processing means a run already owns the episode and has resolved its mode, so enabling would not take effect this run; it is returned only when enabling, since disabling an owned episode is accepted.jobState(string, one ofidle,queued): Present only when enabled is true. queued when anything reached the queue, else idle. Disabling enqueues nothing, so the field is omitted entirely.