API & Webhooks
September 21, 2026 ยท View on GitHub
Contents
API
REST API available at /api/v1/. Interactive docs at /api/v1/docs. Full specification: openapi.yaml.
Authenticated write requests (POST, PUT, PATCH, DELETE) require an X-CSRF-Token header matching the minuspod_csrf cookie. The built-in UI sends it. An external client must echo the cookie on each write. PUT /auth/password is exempt: initial remote setup uses X-MinusPod-Setup-Token, while a later change requires currentPassword. Logout can clear stale unauthenticated state, but an authenticated logout still requires CSRF.
Key endpoints:
GET /api/v1/health- Readiness check (database, storage); returns 503 if either is downGET /api/v1/health/live- Liveness probe (process up); always 200, safe for frequent pollingGET /api/v1/feeds- List feeds. With nopage/limit, returns every feed (unchanged default). Passingpage/limitpaginates the list;includeLatestEpisodes=true(with optionalepisodesPerFeed, default 3, max 20) adds each feed's newest episodes without a separate request per feedPOST /api/v1/feeds- Add a new feed (supportsmaxEpisodesfor RSS cap,onlyExposeProcessedEpisodesto hide unprocessed episodes from the served feed,retentionDaysOverridefor a per-feed retention window or archive,keepOriginalAudioOverridefor the pre-cut original audio)PATCH /api/v1/feeds/{slug}- Update a feed's settings:queuePriority(high/normal/low, restamps the feed's already-queued pending episodes immediately),retentionDaysOverride,keepOriginalAudioOverride,maxEpisodes,onlyExposeProcessedEpisodes,processingMode,chaptersMode, title blacklist, and the other per-feed overrides listed in the OpenAPI specPOST /api/v1/feeds/import-opml- Import feeds from OPML fileGET /api/v1/feeds/export-opml?mode=original|modified- Export feeds as OPML (original or ad-free URLs)POST /api/v1/feeds/refresh-artwork- Re-render every feed's cover art (used after toggling the cover-art badge or swapping the badge asset)POST /api/v1/feeds/{slug}/refresh- Refresh one subscribed feed. Success includes a structuredoutcomewith status, new and queued episode counts, plus refresh timestamps; source fetch or parse failures return 502.POST /api/v1/feeds/refresh- Refresh every subscribed feed. Returns per-feed outcomes and totals; HTTP 207 means the pass completed with at least one feed failure.GET/HEAD /api/v1/feeds/{slug}/artwork- Serve cached feed artwork without admin authentication. Traversal-like or over-200-character legacy slugs are rejected before this public exemption.GET /api/v1/podcast-search?q=query- Search podcasts via PodcastIndex.orgGET /api/v1/feeds/{slug}/episodes- List episodes (supportssort_by,sort_dir,statusfilter, pagination). Each episode carries ajobState(idle,queued, orprocessing) alongside the lifecyclestatus; it is read from the live queue, so apendingepisode with no queue row reportsidlerather thanqueued.POST /api/v1/feeds/{slug}/episodes/bulk- Bulk episode actions (process, reprocess, reprocess_full, reprocess_llm, delete)GET /api/v1/feeds/{slug}/episodes/{id}- Get episode detail with ad markers, transcript, livejobState, and processing-run statistics. New runs include elapsed totals, stage timings, and aggregate FFmpeg time; older runs can have no timing data. The elapsed total ends after the episode and feed are saved; history recording and notifications follow outside itGET /api/v1/feeds/{slug}/episodes/{id}/artwork- Serve an 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 directly. Redirects to the feed cover when the episode has none or the fetch is refused. The URL comes from the episode record, never from the callerPOST /api/v1/episodes/{slug}/{id}/reprocess- Reprocess an episode (bodymode: reprocess/full/llm/recut;llmre-detects on the existing transcript andrecutre-cuts from the saved ad list, both skipping transcription). See Reprocessing for the full mode reference. The olderPOST /api/v1/feeds/{slug}/episodes/{id}/reprocessignoresmodeand always runs a full reprocess.POST /api/v1/feeds/{slug}/episodes/{id}/cancel- Cancel processing for a stuck episodePOST /api/v1/feeds/{slug}/episodes/{id}/regenerate-chapters- Regenerate chapter markers and rewrite the ID3 chapters embedded in the MP3. Returns 202 and runs in the background; the episode'schaptersRegeneratingandchaptersRegenErrorfields report the runPOST /api/v1/feeds/{slug}/reprocess-all- Batch reprocess all episodesGET /api/v1/feeds/{slug}/ad-distribution- Histogram of where ads have historically been cut across the feed's episodes, with learned prior zones. Informational; powers the feed detail Ad Distribution panel and is independent of the learned-positions experiment toggle.POST /api/v1/feeds/{slug}/episodes/{id}/retry-ad-detection- Retry ad detection onlyPOST /api/v1/feeds/{slug}/episodes/{id}/corrections- Submit ad correctionsGET /api/v1/patterns/corrections/unresolved- List legacy corrections with no feed assignment and the evidence for safe candidatesPOST /api/v1/patterns/corrections/{id}/assign- Assign one unresolved correction afterconfirm: trueDELETE /api/v1/patterns/corrections/{id}- Delete one unresolved correction without changing patterns, history, media, or feedsPOST /api/v1/patterns/corrections/unresolved/bulk- Assign or delete 1 to 500 unique correction IDs in one transaction. Both actions needconfirm: true; assignment also needs one feed proven for every row. One stale or invalid row rejects the batch.GET/POST /api/v1/feeds/{slug}/cue-templates- List a feed's audio-cue templates, or mark a new one from a window of an episode's original audio (episodeId,startS,endS,cueType; 0.2 to 10 seconds, up to 60 for show intro/outro)PATCH/DELETE /api/v1/cue-templates/{id}- Enable/disable, change scope (podcastornetwork), set a per-template match threshold (scoreThreshold, 0.30-0.99, null clears), move the capture window (sourceOffsetS/durationS; re-extracts the audio blobs from the retained original, 409 when it has aged out), or delete a templateGET /api/v1/cue-templates/{id}/export- Download a template as a portable zip (lossless WAV plus JSON manifest)POST /api/v1/feeds/{slug}/cue-templates/import- Import a template zip into a feed (multipartfile); the MFCC is recomputed from the WAV, sample-rate or channel mismatches are rejected. The manifest carries aschemaVersionfield that is reserved for a future breaking change; this release only checks that it parses and does not gate or migrate on it.GET /api/v1/feeds/{slug}/episodes/{id}/cue-loud-spots- Template-free energy pass over an episode's original audio; returns candidate "loud spots" the capture UI marks as jump pointsGET /api/v1/feeds/{slug}/episodes/{id}/cue-candidates- Find-audio-cues scan: recurring in-episode stings (speech-like ones dropped) plus intros and outros shared across the feed (powers the Find audio cues button)POST /api/v1/feeds/{slug}/episodes/{id}/cue-candidates/dismiss- Dismiss a candidate sound feed-wide (start_s,end_s, optionallabel; spans over 120 seconds are rejected). Stores the span's fingerprint from the retained original; future candidate scans suppress matching soundsGET /api/v1/feeds/{slug}/cue-candidate-dismissals- List the feed's dismissed sounds, newest firstDELETE /api/v1/cue-candidate-dismissals/{id}- Undo a dismissal; the sound becomes suggestible againPOST /api/v1/feeds/{slug}/episodes/{id}/cue-scan- Diagnostic: run every enabled template against an episode and return per-template peak scores and match times (optionalscoreThresholdoverride)POST /api/v1/feeds/{slug}/episodes/{id}/cue-template-preview- Run a single template (templateId) against an episodePOST /api/v1/feeds/{slug}/cue-cross-episode-scan- Full-body cross-episode scan for recurring segments (episodeIds, 2-5; the first sets the coordinate frame). Poll with the same body;rescan: trueforces a fresh runPOST /api/v1/feeds/{slug}/cue-templates/{id}/optimize-window- Sweep start/end trims (up to 0.5s each way, 0.1s steps) for the window with the best mean match score across the source episode and up to 4 siblings; 409 when the source original has aged outGET /api/v1/detections- List ad detections across all feeds with status filter (needs_review,pending,rejected,accepted,all; defaultneeds_review), optional podcast slug (feed), free-text search (q), sort (date,confidence,podcast), order (asc,desc), and pagination (page,limit1-100, default 20). Powers the Patterns > Ad Review tab.GET /api/v1/patterns- List ad patterns (filter by scope)GET /api/v1/patterns/stats- Pattern database statisticsGET /api/v1/sponsors- List/create/update/delete sponsors (full CRUD)GET /api/v1/search?q=query- Full-text search across all content, grouped into shows, episodes, transcripts, patterns and sponsors. Optionalgroups(comma-separated subset of those five, default all) limits which are computed; an unrequested group is returned empty rather than omitted. Names are case-sensitive, empty tokens from a stray comma are ignored, and duplicates are tolerated. A query shorter than two characters returns every group empty. The oldtypeparameter was removed; a request that still sends it gets a 400GET /api/v1/episodes/processing- List episodes currently processingGET /api/v1/history- Processing history with pagination and exportGET /api/v1/stats/dashboard- Aggregate stats (avg/min/max time saved, ads, cost, tokens) with optional podcast filterGET /api/v1/stats/by-day- Episodes processed by day of weekGET /api/v1/stats/by-podcast- Per-podcast stats (ads, time saved, tokens, cost)GET /api/v1/status- Current processing status with an ETag; the browser polls every 2 seconds and backs off to 30 seconds after failuresGET /api/v1/status/stream- Authenticated SSE compatibility endpoint, limited to 30 seconds and bounded worker slotsGET /api/v1/system/updates- Latest stable and edge release info from GitHub Releases, cached 6 hours in-process (?refresh=trueforces a live fetch, throttled to once per 30 seconds); returns 502 if GitHub is unreachableGET /api/v1/system/token-usage- LLM token usage and cost breakdown by modelGET /api/v1/system/model-pricing- All known LLM model pricing ratesPOST /api/v1/system/model-pricing/refresh- Force refresh pricing from provider sourceGET /api/v1/system/queue- Auto-process queue statusPOST /api/v1/system/vacuum- Trigger SQLite VACUUM to reclaim disk spaceGET /api/v1/system/status- Active Podping node, per-node HTTP status, and manual check progress. Includes worker-scoped SQLite WAL, checkpoint, transaction, and busy diagnosticsPOST /api/v1/system/podping/check- Check every Podping RPC node without changing the listener's active node; the request is deduplicated while a check is runningPOST /api/v1/system/database/checkpoint- Run a passive WAL checkpoint; returns 409 when active readers prevent completionGET /api/v1/system/backup- Download an encrypted SQLite backup by default. Returns 409 when encryption is unavailable;encrypted=falseexplicitly requests plaintext. See the security guidePOST /api/v1/system/db-backup/run- Run a scheduled-style backup now, writing a plain SQLite snapshot to the configured destination (rate-limited to 6/hour; 409 if one is already running)GET/PUT /api/v1/settings/db-backup- Get or update scheduled backup settings (enabled,cron,dest,keepCount)GET/PUT /api/v1/settings/provider-budget- Read or update durable provider admission settings and current reserved/spent amountsGET /api/v1/settings/provider-budget/currencies- List supported ISO display currenciesGET /api/v1/settings/provider-budget/rate/{currency}- Fetch a current USD reference rate and optional converted preview amountsGET/POST /api/v1/feeds/{slug}/subscriber-keys- List scoped subscriber credentials or create one; the secret and feed URL appear only in the create responseDELETE /api/v1/feeds/{slug}/subscriber-keys/{id}- Revoke one scoped subscriber credential without changing global or sibling credentialsDELETE /api/v1/feeds/{slug}/subscriber-keys/{id}/record- Delete the audit record for a revoked key. Active keys return 409; deleting the record never revalidates the token.GET /api/v1/settings- Get current settings (includes LLM provider, API key status)GET/PUT /api/v1/settings/retention- Get or update retention configuration.retentionDayscontrols how long the processed audio survives;originalRetentionDayscontrols the pre-cut original separately. Server clampsoriginalRetentionDaystoretentionDayson save.GET/PUT /api/v1/settings/audio- Toggle whether originals are kept for ad editor review (keepOriginalAudio)GET/PUT /api/v1/settings/processing-timeouts- Soft and hard processing timeouts in secondsGET/PUT /api/v1/settings/update-check- Get or update the update-check settings (enabledfor the daily auto-check,channel:stableoredge)GET /api/v1/feeds/{slug}/episodes/{id}/original.mp3- Stream the retained pre-cut audio (used by ad editor Review mode)PUT /api/v1/settings/ad-detection- Update ad detection config, including a partialmodelPricingOverridesmap. Each model entry has input and output prices in USD per 1 million tokens;nullremoves an override.GET /api/v1/settings/models- List available AI models from current providerPOST /api/v1/settings/models/refresh- Force refresh model list from provider. Optional JSON body{"slot": "primary" | "secondary"}picks the credential slot (default primary).POST /api/v1/settings/rate-limit-hold/reset- Clear every active rate-limit hold without disabling the hold featureGET/POST/PUT/DELETE /api/v1/settings/webhooks- Webhook CRUDPOST /api/v1/settings/webhooks/{id}/test- Fire test webhookPOST /api/v1/settings/webhooks/validate-template- Validate and preview a payload templateGET/PUT /api/v1/settings/notifications/email- Email notification settingsPOST /api/v1/settings/notifications/email/test- Send a test email
Queue hold state
GET /api/v1/status and every frame of GET /api/v1/status/stream carry a hold block saying why the queue is not moving. It reports what the maintenance tick last observed and never probes a service itself, so polling it makes no provider or Whisper request.
{
"hold": {
"queuePaused": true,
"holdUntil": "2026-01-01T12:30:00Z",
"holdSince": "2026-01-01T11:45:00Z",
"offlineHeld": 2,
"offlineServices": [
{"service": "whisper", "held": 2, "reachable": false, "checkedAt": "2026-01-01T11:58:00Z"}
]
},
"jobs": [
{
"slug": "my-favorite-podcast",
"episodeId": "a1b2c3d4e5f6",
"title": "Episode 42: The Answer",
"podcastName": "My Favorite Podcast",
"stage": "pass1:transcribing",
"progress": 35,
"startedAt": 1767261900,
"elapsed": 120
}
],
"whisper": {
"enabled": true,
"backend": "openai-api",
"active": true,
"inactiveReason": null,
"capacity": 4,
"inFlight": 2,
"transcribingEpisodes": 1,
"maxEpisodes": {"configured": 2, "effective": 2},
"leader": true
}
}
queuePaused is true only while a rate-limit hold is stopping new claims; holdUntil is the provider's own reset time and holdSince is when the pause began, both null once the reset has passed. An offline wait parks specific episodes and leaves the rest of the queue running, so it never sets queuePaused. offlineHeld counts every episode deferred outside the rate-limit hold, including any service offlineServices does not break out. A service's reachable is null until the tick has probed it once, which means "not checked yet" rather than "up".
jobs lists every running job, oldest first, with the same fields as currentJob, which stays as the oldest one. whisper is the pool snapshot: whether it is on and active, the request cap, and how many requests and episodes are in flight. Pool counters are per gunicorn worker, so inFlight and transcribingEpisodes are only meaningful when leader is true; a non-leader worker always reports those as 0.
Public feed-domain routes
A handful of routes live on the feed domain itself, outside /api/v1, and are not part of the OpenAPI spec: the served RSS feed at /{slug}, episode audio at /episodes/{slug}/{episodeId}.mp3, transcripts and chapters (.vtt, /chapters.json), feed cover art at /{slug}/cover-minuspod.jpg, and:
GET /episodes/{slug}/{episodeId}/artwork- Serve a cached per-episode cover. Local-feed episodes get one whenever an upload, import, or embedded-artwork extraction cached one; 404 if nothing is cached (never fetches on demand). This is the URL a local feed's per-item<itunes:image>points at.
All of these are gated by the feed auth key the same way as the RSS feed itself when Authenticated feeds is on; see Security > Authenticated feeds.
GET and HEAD enforce the same feed key on every public route. A HEAD request for unprocessed subscribed audio proxies the upstream headers and never starts processing. Local audio answers from the retained original. This lets podcast apps inspect media without filling the queue.
Notifications
MinusPod can notify you when episodes complete processing or permanently fail, and when the LLM provider rejects requests (bad credentials, exhausted spend limits, oversized requests). It can also alert you when a rate-limit hold or an unreachable endpoint parks the queue. Two channels share the same events: webhooks (HTTP POST to any endpoint) and native email through your own SMTP server. Configure both in Settings > Notifications in the web UI, or via the REST API.
Every payload carries timestamp (UTC, Z-suffixed) and timestamp_local (the same instant in the configured notification_timezone, with a UTC offset). notification_timezone is an IANA zone name (default UTC, or the container's TZ env var when it names a valid zone); get or set it at GET/PUT /api/v1/settings/notifications/timezone. Email shows timestamp_local in the Timestamp row, falling back to timestamp when the zone is UTC.
Webhooks
Webhooks fire an HTTP POST to configured URLs. Works with any HTTP endpoint. Use a custom Jinja2 payload template to match the receiver's expected format.
Events
| Event | Fires when |
|---|---|
Episode Processed | Episode completes processing successfully |
Episode Failed | Episode reaches permanently failed status |
Auth Failure | LLM provider rejects the API key as invalid or expired (401/403 without billing markers; rate-limited to one per 5 minutes) |
Limit Exceeded | LLM provider rejects a request because a spend or usage limit is exhausted: a monthly key limit (OpenRouter 403), out of credits (402, Anthropic low balance), or OpenAI insufficient_quota (rate-limited to one per 5 minutes). The key is valid; add credits or raise the limit, then reprocess the episode (it is marked permanently failed rather than retried). |
Rate Limit Structural | A single detection-window request exceeds the provider's per-minute token cap (rate-limited to one per 5 minutes). Retrying will not help; the operator needs to shrink the detection window or move to a higher tier. |
Feed Refresh Failed | A feed's upstream RSS fetch fails 3 times in a row. One alert per feed per 5 minutes, with a shared burst cap so an outage that breaks every feed at once sends one alert, not one per feed. |
Update Available | The daily update check finds a newer release on the selected channel (stable or edge); fires once per version |
Cue Template Quiet | An enabled audio cue template on a cue_only feed has matched before but has zero above-threshold matches across the feed's last 5 telemetry-recorded episodes. Rate-limited to one alert per template per 5 minutes. |
Queue Held | A provider 429 with a reset time paused the queue (Queue Control > Rate-limit hold). One alert per pause; a later 429 that extends the pause is silent. |
Queue Resumed | The rate-limit hold cleared and the queue is claiming work again. One alert per 5 minutes. |
Service Offline | An episode deferred because the LLM or Whisper endpoint was unreachable (Offline queue). One alert per service per 5 minutes. |
Service Reachable | The offline probe found a service back up and re-queued its deferred episodes. One alert per service per 5 minutes. |
The Test button sends one sample payload per event the webhook is subscribed to, each shaped like that event's real payload (see Default Payloads below) with test: true set. A webhook subscribed to three events gets three test deliveries in one click; a custom payload template renders against each event's own variable set (episode-shaped for Episode Processed/Episode Failed, provider-shaped for the alert events, and so on).
Template Variables
Custom payload templates are Jinja2 strings rendered against these variables:
| Variable | Type | Description |
|---|---|---|
event | string | Episode Processed, Episode Failed, Auth Failure, Limit Exceeded, Rate Limit Structural, Feed Refresh Failed, Update Available, Cue Template Quiet, Queue Held, Queue Resumed, Service Offline, or Service Reachable |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
podcast.name | string | Podcast title (falls back to slug if unavailable) |
podcast.slug | string | Feed slug |
episode.id | string | Episode ID |
episode.title | string | Episode title |
episode.slug | string | Feed slug |
episode.url | string | Full UI URL to episode |
episode.ads_removed | int | Number of ads removed |
episode.ads_held | int | Number of ads held for review (not cut) |
episode.ads_not_cut | int | Number of detections not cut (rejected by validation, below threshold, or vetoed by the reviewer) |
episode.processing_time_secs | float | Processing duration in seconds |
episode.processing_time | string | Processing duration formatted as M:SS or H:MM:SS |
episode.llm_cost | float | LLM cost in USD |
episode.llm_cost_display | string | LLM cost formatted as $X.XX |
episode.time_saved_secs | float/null | Seconds of audio removed |
episode.time_saved | string/null | Time saved formatted as M:SS or H:MM:SS |
episode.error_message | string/null | Error message (failed events only) |
test | bool | true only on test webhook fires; absent on real events |
Auth Failure events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Auth Failure |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
provider | string | LLM provider name (anthropic, openrouter, etc.) |
model | string | Model that failed authentication |
error_message | string | Error details from the provider |
status_code | int/null | HTTP status code (401 or 403) |
Limit Exceeded events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Limit Exceeded |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
provider | string | LLM provider name (openrouter, openai, etc.) |
model | string | Model the rejected request targeted |
error_message | string | Error details from the provider |
status_code | int/null | HTTP status code (402, 403, 429, or 400 depending on provider) |
Rate Limit Structural events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Rate Limit Structural |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
provider | string | LLM provider name |
model | string | Model that returned the 429 |
limit | int | The provider's per-minute token cap |
used | int | Tokens already consumed in the current minute |
requested | int | Tokens this request asked for (greater than limit means the request structurally cannot fit) |
error_message | string | Raw error details from the provider |
Feed Refresh Failed events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Feed Refresh Failed |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
slug | string | Feed slug |
podcast_name | string | Podcast title (falls back to the slug) |
feed_url | string | Upstream feed URL, with any credentials stripped |
error_message | string | Error from the most recent fetch attempt |
failure_count | int | Consecutive failed refreshes when the alert fired |
Update Available events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Update Available |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
version | string | Version number of the newer release |
channel | string | Channel the release was found on: stable or edge |
release_date | string/null | Release date (YYYY-MM-DD) |
release_url | string/null | GitHub release page URL |
Cue Template Quiet events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Cue Template Quiet |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
podcast.name | string | Podcast title (falls back to slug if unavailable) |
podcast.slug | string | Feed slug |
template.id | int | Cue template ID |
template.label | string | Cue template label |
last_match_at | string/null | Timestamp of the template's last above-threshold match before it went quiet |
Queue Held events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Queue Held |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
hold_until | string | The provider's reset time; the queue claims no new work until then |
hold_until_local | string | The same reset time in the notification_timezone setting, with UTC offset |
error_message | string | The 429 response that triggered the hold |
slug | string | Feed slug of the episode that hit the limit |
episode_id | string | ID of that episode |
podcast_name | string | Podcast title (falls back to the slug) |
Queue Resumed events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Queue Resumed |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
held_since | string/null | When the hold began; null when no start time was recorded |
Service Offline events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Service Offline |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
service | string | llm or whisper |
error_message | string | The connection error that deferred the episode |
slug | string | Feed slug of the deferred episode |
episode_id | string | ID of that episode |
podcast_name | string | Podcast title (falls back to the slug) |
Service Reachable events use a different payload:
| Variable | Type | Description |
|---|---|---|
event | string | Service Reachable |
timestamp | string | ISO 8601 UTC timestamp |
timestamp_local | string | ISO 8601 local timestamp with UTC offset, per the notification_timezone setting |
service | string | llm or whisper |
requeued | int | Deferred episodes the probe pass sent back to the queue |
Default Payloads
When no custom template is configured, MinusPod sends these JSON payloads.
Episode Processed:
{
"event": "Episode Processed",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"podcast": {
"name": "My Favorite Podcast",
"slug": "my-favorite-podcast"
},
"episode": {
"id": "a1b2c3d4e5f6",
"title": "Episode 42: The Answer",
"slug": "my-favorite-podcast",
"url": "http://your-server:8000/ui/feeds/my-favorite-podcast/episodes/a1b2c3d4e5f6",
"ads_removed": 3,
"ads_held": 1,
"ads_not_cut": 1,
"processing_time_secs": 42.5,
"processing_time": "0:42",
"llm_cost": 0.0035,
"llm_cost_display": "\$0.00",
"time_saved_secs": 187.0,
"time_saved": "3:07",
"error_message": null
}
}
Episode Failed:
{
"event": "Episode Failed",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"podcast": {
"name": "My Favorite Podcast",
"slug": "my-favorite-podcast"
},
"episode": {
"id": "a1b2c3d4e5f6",
"title": "Episode 42: The Answer",
"slug": "my-favorite-podcast",
"url": "http://your-server:8000/ui/feeds/my-favorite-podcast/episodes/a1b2c3d4e5f6",
"ads_removed": 0,
"ads_held": 0,
"ads_not_cut": 0,
"processing_time_secs": 12.3,
"processing_time": "0:12",
"llm_cost": 0.001,
"llm_cost_display": "\$0.00",
"time_saved_secs": null,
"time_saved": null,
"error_message": "Transcription failed: audio file is corrupt or unsupported format"
}
}
Auth Failure:
{
"event": "Auth Failure",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"provider": "anthropic",
"model": "claude-sonnet-4-20250514",
"error_message": "Invalid API key provided",
"status_code": 401
}
Limit Exceeded:
{
"event": "Limit Exceeded",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4",
"error_message": "Key limit exceeded (monthly limit). Manage it using https://openrouter.ai/settings/keys",
"status_code": 403
}
Rate Limit Structural:
{
"event": "Rate Limit Structural",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"provider": "groq",
"model": "llama-3.3-70b-versatile",
"limit": 6000,
"used": 2400,
"requested": 8500,
"error_message": "rate_limit_exceeded: Request too large for model on tokens per minute"
}
Feed Refresh Failed:
{
"event": "Feed Refresh Failed",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"slug": "my-podcast",
"podcast_name": "My Podcast",
"feed_url": "https://feeds.example.com/my-podcast",
"error_message": "HTTP 522 from upstream",
"failure_count": 3
}
Update Available:
{
"event": "Update Available",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"version": "2.74.0",
"channel": "stable",
"release_date": "2026-07-22",
"release_url": "https://github.com/ttlequals0/MinusPod/releases/tag/v2.74.0"
}
Cue Template Quiet:
{
"event": "Cue Template Quiet",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"podcast": {
"name": "My Favorite Podcast",
"slug": "my-favorite-podcast"
},
"template": {
"id": 3,
"label": "break stinger"
},
"last_match_at": "2026-03-01T00:00:00Z"
}
Queue Held:
{
"event": "Queue Held",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"hold_until": "2026-01-01T12:30:00Z",
"hold_until_local": "2026-01-01T12:30:00+00:00",
"error_message": "rate_limit_exceeded: retry after 900 seconds",
"slug": "my-podcast",
"episode_id": "a1b2c3d4e5f6",
"podcast_name": "My Podcast"
}
Queue Resumed:
{
"event": "Queue Resumed",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"held_since": "2026-01-01T12:15:00Z"
}
Service Offline:
{
"event": "Service Offline",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"service": "llm",
"error_message": "Connection refused",
"slug": "my-podcast",
"episode_id": "a1b2c3d4e5f6",
"podcast_name": "My Podcast"
}
Service Reachable:
{
"event": "Service Reachable",
"timestamp": "2026-04-12T00:15:42Z",
"timestamp_local": "2026-04-12T00:15:42+00:00",
"service": "llm",
"requeued": 3
}
Email notifications
Point MinusPod at an SMTP server and it emails you for the events you pick. Community webhook-to-email sidecars like minuspod-webhook-mailer are no longer needed. One configuration: SMTP host, port, security (None, STARTTLS, or SSL/TLS), optional username and password, a from address, and a comma-separated recipient list. The password is stored encrypted like provider API keys, so saving one needs MINUSPOD_MASTER_PASSPHRASE set.
Emails are HTML with the MinusPod logo embedded inline (no external image fetch) and a plain-text fallback part for text-only clients. Each event renders a subject like [MinusPod] Episode Failed: My Show - Episode 42 with a short table of facts and, for alert events, the action to take. Alert events (Auth Failure, Limit Exceeded, Rate Limit Structural, Queue Held, Queue Resumed, Service Offline, Service Reachable) keep their 5-minute dedup window, shared with webhooks, so a burst of failures produces one email. The webhook Test button never emails; the email form has its own Send test email button that delivers a real message through the saved settings.
By default the failure and alert events, including the four new hold and offline events, are checked and Episode Processed is not, so a working setup stays quiet. SMTP sending runs with a 10 second timeout in a background thread; a down mail server never blocks or fails episode processing. An Episode Processed email adds an "Ads held for review" and/or "Detections not cut" row when the run produced either, so a quiet run's table stays short. A send failure logs the full traceback (issue #571) rather than just the exception message, for easier SMTP troubleshooting from container logs.
Example: Pushover
Pushover supports native webhook ingestion with data extraction selectors. No custom payload template needed. MinusPod's default JSON payload works directly.
- Create a webhook at pushover.net/dashboard and copy its URL.
- In MinusPod Settings > Webhooks: paste the URL, select events, leave payload template blank.
- Click Test in MinusPod to fire a sample payload to Pushover.
- In Pushover, load the last payload and configure data extraction selectors:
| Field | Selector |
|---|---|
| Title | {{podcast.name}} - {{event}} |
| Body | {{episode.title}}{{episode.ads_removed}} ads removed. Saved {{episode.time_saved}}. Cost {{episode.llm_cost_display}} |
| URL | {{episode.url}} |
| URL Title | Open in MinusPod |
Pushover's
{{...}}selector syntax is evaluated on Pushover's side; these are not Jinja2 templates.
Example: ntfy
ntfy requires a custom payload template to match its expected JSON format.
- Self-hosted or ntfy.sh: set your topic name
- Add a webhook in Settings > Webhooks:
- URL:
https://ntfy.sh/your-topic(or your self-hosted instance) - Payload template:
{ "topic": "your-topic", "title": "{{ podcast.name }} - {{ episode.title }}", "message": "Removed {{ episode.ads_removed }} ads in {{ episode.processing_time }}. Cost {{ episode.llm_cost_display }}", "actions": [{"action": "view", "label": "Open Episode", "url": "{{ episode.url }}"}] }
- URL:
ntfy also supports header-based delivery (
X-Title,X-Message,X-Clickheaders with plain text body); either approach works with MinusPod's template system.
Request Signing
If a webhook has a secret configured, MinusPod adds an X-MinusPod-Signature: sha256=<hmac> header to each POST, computed with HMAC-SHA256 over the request body.