Skip to content

Public API Worker

The Public API Worker serves the keyless, read-only public API (v1) and delivers the embeddable player. One deployment answers on three hostnames: api.podcasterplus.com (JSON API + oEmbed), embed.podcasterplus.com (player script + iframe pages), and — additively since Epic 16 GATE-2 — embed.cdn.media (the player's forever-URL home after the show.fm flip; the old embed host keeps serving indefinitely, because embeds in the wild hold it). The architecture mirrors the RSS Feed Worker — Hyperdrive, KV marker-cache, queue-driven invalidation — and the endpoint conventions it implements are the binding standard for all future public endpoints: see Public API Conventions.

The worker is read-only. The only mutation surface is POST /_internal/invalidate, behind API_INVALIDATION_SECRET.

Overview

PropertyValue
URLshttps://api.podcasterplus.com/v1/* and https://embed.podcasterplus.com/*
Worker Namepodcasterplus-public-api
Source Location/workers/public-api/
TriggerHTTP + Queue consumer (public-api-invalidation)
FrameworkHono (worker-local dependency, no shared code with the app)
DatabaseSupabase PostgreSQL via Hyperdrive (required — no REST fallback)
CacheCloudflare KV (PUBLIC_API_CACHE) + edge cache + static assets
CPU Limitcpu_ms = 500
Public docsdocs.podcasterplus.com/developers

Architecture

src/index.ts routes by hostname against a host SET (resolveEmbedHosts): the comma-separated PUBLIC_EMBED_HOSTS var plus, always, the PUBLIC_EMBED_URL host — so with the list unset the behaviour is the original single-host split, and during/after the domain transition BOTH embed hosts reach the embed app. Everything else (api.podcasterplus.com, workers.dev previews, localhost dev) goes to the JSON API app.

Hostnames & Routing

toml
# workers/public-api/wrangler.toml
routes = [
  { pattern = "api.podcasterplus.com", custom_domain = true, zone_name = "podcasterplus.com" },
  { pattern = "embed.podcasterplus.com", custom_domain = true, zone_name = "podcasterplus.com" }
]

Both hosts share the KV cache and the content loaders (src/lib/content.ts) — an iframe page render and a GET /v1/episodes/{id} hit populate and consume the same cached payload.

API Endpoints (api.podcasterplus.com)

EndpointReturns
GET / and GET /v1Index JSON linking to the public docs
GET /health{ status: 'ok', service: 'public-api' }
GET /v1/podcasts/{slug}Podcast payload (metadata, episode_count, links, branding, player_color, player_theme, player_waveform)
GET /v1/podcasts/{slug}/episodes?limit&cursorKeyset-paginated published episodes; limit default 20 / max 50
GET /v1/podcasts/{slug}/episodes/latestNewest published episode (full episode payload)
GET /v1/podcasts/{slug}/episodes/{idOrSlug}One episode by UUID or slug; UUID refs are checked against the path slug so a foreign UUID under another show's slug 404s
GET /v1/episodes/{id}Episode by bare UUID — the player's one-request lookup
GET /v1/oembed?url&format&maxwidth&maxheightoEmbed rich response; pure URL parsing, never fetches the target
POST /_internal/invalidateCache invalidation, Bearer API_INVALIDATION_SECRET

All public routes answer GET/HEAD (+ CORS OPTIONS). Payload shapes are the v1 contract, defined in workers/public-api/src/types/env.ts (PodcastPayload, EpisodeListItemPayload, EpisodePayload, PodcastSummaryPayload, BrandingPayload) — additive changes only; renames/removals require /v2.

Numeric payload fields sourced from BIGINT columns MUST coerce

The postgres client returns int8/numeric as strings (no number parser for OID 20/1700), and JSON.stringify happily emits them quoted, so a raw selection silently violates the contract's number typing. It happened live: audio.size_bytes shipped as "6760716" (a string) on every self-hosted episode until 2026-08-20, while duration_seconds (an int4 column) sat beside it as a number. The payload mapper now Number()-coerces audio_file_size_bytes; any new payload field backed by a BIGINT/NUMERIC column (or an uncast SQL aggregate — prefer count(*)::int in the query, as episode_count does) needs the same treatment, pinned by a wire-type test in lib-units.test.ts.

Every episode payload carries people (credited names + roles, already public as <podcast:person>) and transcript ({ url, type } or null), added 2026-08-12 for the listen-page redesign. people is a correlated json_agg in EPISODE_COLUMNS, not a join, so one episode still yields one row and the keyset pagination is untouched; it is backed by idx_episode_credits_episode. Both fields exist because the listen page's archive pages in through /v1/podcasts/{slug}/episodes, so a row rendered from the API must carry the same fields as one the page server-rendered, or the list visibly changes shape at the Load-more boundary. Consumers never branch on null: no credits normalises to [], and a transcript with no stored type defaults to text/vtt. Payloads cached before this shipped simply lack the fields, so the page reads them defensively. Envelope, error codes, pagination cursor format, and header conventions are specified in Public API Conventions.

Episode listen links use the /e/ segment — https://listen.podcasterplus.com/{podcastSlug}/e/{episodeSlug} — a route-conflict guard against the public booking page's /{slug}/{slug} pattern. The oEmbed URL parser accepts exactly the two listen shapes (podcast root, episode under /e/), hostname-validated against PUBLIC_LISTEN_URL.

Embed Endpoints (embed.podcasterplus.com)

EndpointReturns
GET /player/v1.jsBuilt custom-element bundle via ASSETS; Cache-Control: public, max-age=3600, stale-while-revalidate=86400, Access-Control-Allow-Origin: *
GET /ep/{episodeId}?theme=&size=Iframe page for one episode
GET /latest/{podcastSlug}?theme=&size=Iframe page that always plays the newest episode
GET /health{ status: 'ok', service: 'public-api-embed' }
GET /robots.txtDisallow: /

Iframe pages are tiny HTML shells: <showfm-player> + <script async src="/player/v1.js"> + a fully functional <noscript> native <audio> fallback (the worker already holds the payload). Query params are allowlist-validated (theme ∈ auto|light|dark, size ∈ standard|compact, accent = 3/6-digit hex with optional hash normalized to #rrggbb, wave ∈ true|false). Theme/accent/wave params PIN the value by stamping the matching element attribute; when absent (or invalid) NO attribute is stamped and the element follows the show's saved player settings from the payload it fetches, so Distribution-settings changes reach already-cached shells without an invalidation (#297 item 3). Size always stamps (geometry: the iframe height is fixed at paste time); junk sizes fall back to standard.

Headers on iframe pages — they MUST be frameable and non-indexable:

  • Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src https: data:; media-src https:; connect-src {API origin}; frame-ancestors *; base-uri 'none'; form-action 'none' (no X-Frame-Options; style-src 'unsafe-inline' because Svelte's custom-element runtime injects component styles into the shadow root)
  • X-Robots-Tag: noindex
  • No cookies anywhere on this host, ever.

Iframe shells are additionally fronted by caches.default, keyed on the full URL (including theme/size): /ep/{id} gets s-maxage=300; /latest/{slug} gets s-maxage=60 so it picks up new episodes quickly.

KV Marker Cache

KV payload cache with marker-based invalidation (src/cache/kv.ts), following the rss-feed pattern: KV writes propagate faster than deletes, so invalidation writes a timestamp marker that reads compare against, and deletes the known keys as a backup.

Key structure:

pod:{slug}          # podcast payload
ep:{episodeId}      # single-episode payload (embedded podcast summary)
eplist:{slug}       # FIRST page of the episodes list at the default limit only
eplatest:{slug}     # latest-episode payload
invalidated:{slug}  # invalidation marker (timestamp)

Entries store { payload, etag, generatedAt, podcastSlug } with a 3600 s TTL. On read, an entry is checked against invalidated:{podcastSlug} — if the entry's generatedAt predates the marker, it's treated as a miss and rebuilt. Episode-keyed entries carry podcastSlug internally precisely so a podcast-scoped marker can reject them.

Marker TTL is 3600 s here — not rss-feed's 300 s

rss-feed can enumerate everything it caches for a slug (feed:{slug}*), so its marker only needs to outlive KV delete propagation. Here, ep:{episodeId} keys cannot be enumerated per podcast slug — the queue consumer only deletes the episode ids it was told about. Any other episode entry survives the deletes and is only rejected by the marker comparison, so the marker TTL must cover the full entry TTL (3600 s). Shortening the marker TTL would let stale episode payloads resurface.

Only the hot list page is cached: GET …/episodes with a cursor or a non-default limit goes straight to Hyperdrive. Slug-addressed episode lookups can't be cache-keyed ahead of time (the id is unknown until resolved) but store their result under ep:{id} so id-keyed reads benefit.

Response caching: every successful payload response carries Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600, a strong ETag (SHA-hash of the payload JSON), and X-Cache: HIT|MISS; If-None-Match returns 304. 404s are cached public, max-age=60; 5xx are no-store.

Cache Invalidation

Same message shape as RssInvalidationMessage — the main app fans one logical invalidation out to both the RSS and public-api transports.

typescript
// workers/public-api/src/types/env.ts
interface InvalidationMessage {
	type: string; // e.g. 'episode_published', 'player_branding_changed'
	podcast_id: string;
	podcast_slug: string;
	episode_id?: string;
	timestamp: string;
}

Fan-out from the main app

fanOutToPublicApiCache() in src/api/routes/rss.ts piggybacks on POST /api/rss/invalidate, so every existing caller of src/lib/utils/rss-invalidate.ts gets public-api invalidation for free. Delivery strategy:

CaseTransport
Immediate invalidationHTTP-first (POST https://api.podcasterplus.com/_internal/invalidate with Bearer API_INVALIDATION_SECRET), queue fallback on failure
Non-immediateQueue first, HTTP fallback
Delayed (delaySeconds)Queue only (HTTP can't schedule the future)

The fan-out is best-effort: the RSS leg is authoritative for the caller's response; public-api failures are logged only. src/api/utils/podcast-cleanup.ts#invalidatePodcastFeed also sends to both queues (covers the direct-queue call sites). Additional invalidation triggers: the updatePlayerBranding form action (reason player_branding_changed) and the Stripe webhook side-effects block on plan changes (branding entitlement may have flipped).

Root wrangler.toml producer binding: PUBLIC_API_INVALIDATION_QUEUE → queue public-api-invalidation. The lifecycle-manager worker is a second producer (downgrade-overages §7.4): its overage escalation sweep sends overage_suspended / overage_restored messages (same shape) for each podcast it stamps or restores, paired with the RSS leg's synchronous HTTP invalidation.

Queue consumer

toml
[[queues.consumers]]
queue = "public-api-invalidation"
max_batch_size = 10
max_batch_timeout = 30
max_retries = 3

The consumer (in src/index.ts) mirrors rss-feed's durability contract: group messages by slug; write the marker + delete pod:/eplist:/eplatest: keys plus ep:{id} for every distinct episode_id in the group; ack() only after the invalidation succeeded, retry() on failure; ack malformed messages (missing podcast_slug) as poison so they don't loop.

HTTP endpoint

POST /_internal/invalidateAuthorization: Bearer {API_INVALIDATION_SECRET}; body { "slug": string, "episode_id"?: uuid, "reason"?: string }; validates slug/UUID before acting; responds { "success": true, "slug": "…" }.

Database Access & Visibility Contract

src/db/client.ts uses Hyperdrive + postgres.js (max: 1, sql.end() in finally, shared Hyperdrive instance). There is no REST fallback: a missing binding throws DatabaseUnavailableError, surfaced as 503 service_unavailable — fail closed.

Every query enforces the visibility contract (stated in-code at workers/public-api/src/db/client.ts:5-18 — that header is authoritative; this page must not drift from it. It mirrors workers/rss-feed/src/db/client.ts and the listen pages — the API must never expose more than the RSS feed already does):

sql
-- podcasts
hosting_type = 'podcasterplus' AND status IN ('active', 'paused') AND is_active = true
  AND overage_suspended_at IS NULL   -- downgrade-overages §7.4
-- episodes
status = 'published' AND published_at <= now() AND is_blocked IS NOT TRUE

overage_suspended_at IS NULL is the hosted-content suspension gate (downgrade-overages §7.4): the stamp is platform-owned (written only by the lifecycle-manager escalation sweep and the webhook reconciler; user-JWT writes raise 42501), and while it is set the v1 API and embed player 404 the show exactly as the RSS feed does. Suspend/restore fans a public-api-invalidation message out per podcast so the KV payload cache drops immediately.

Deltas from rss-feed, both deliberate:

  • pending_deletion shows are NOT served (RSS keeps them for its migration-grace flow; embeds have no such need — matches the listen pages).
  • owner_email, email_visible_until, and verification tokens are never selected. Explicit column lists only, no SELECT *. The RSS timed-email-reveal logic does not carry over — the API omits email entirely.

The bare-UUID lookup (GET /v1/episodes/{id}) joins the parent podcast with the full podcast filters — an episode UUID must never leak past podcast gating. Episode lists use keyset pagination (WHERE (published_at, id) < (cursor) ORDER BY published_at DESC, id DESC, fetching limit + 1 rows to derive next_cursor).

Input validation runs before any DB round-trip: slug regex ^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]?$ (same as rss-feed), strict UUID parse, cursor decode-and-validate. Invalid → 400, unknown → 404, never a 500 on malicious ids.

Branding Resolution

branding.show_powered_by is computed at cache-build time in SQL + mapper:

show_powered_by = NOT (
  public.feature_enabled(podcasts.billing_account_id, 'player_branding_removal')
  AND podcasts.hide_player_branding
)
  • public.feature_enabled is SECURITY DEFINER (migration 20260622140000) and worker-callable over Hyperdrive.
  • hide_player_branding is the podcast-level preference (Settings → Distribution → Embed player); the entitlement (player_branding_removal, Creator+) is re-checked here on every rebuild, so plan downgrades self-heal without sweeps.
  • Staleness is bounded by the 1 h KV TTL plus explicit invalidation on toggle and on plan change.

The player renders the "Powered by show.fm" footer whenever show_powered_by is true; the dashboard preview resolves the same boolean server-side so preview and production always agree.

Player Color

player_color (podcasts column, migration 20260707082954, CHECK #rrggbb) is the show-wide player accent, selected into every podcast payload and summary. Its siblings player_theme and player_waveform (migration 20260812110000, alongside app-side player_size) ride the same payloads: an embed without a pinned theme/wave attribute resolves them from the payload (PodcasterplusPlayer.svelte), falling back to auto/waveform-on for payloads cached before the fields shipped. player_size is deliberately NOT in the payload: size is geometry baked into pasted embed code (iframe height, reserved min-height), so it only seeds the dashboard builders. Precedence in the player: accent attribute (per-embed pin) → player_color → legacy brand_color → default purple #7E22CE. The app's updatePlayerColor action invalidates the public-api cache on change (podcast.player_color.updated), same path as the branding toggle. The powered-by wordmark deliberately does NOT follow the accent — it uses the palette's --pp-logo token (brand purple in light, white in dark). No entitlement gate: color is available on every plan.

Bindings

BindingTypeValue / Notes
ASSETSStatic assets./assets — built player bundle; run_worker_first = true (API/iframe routes are dynamic; /player/* falls through)
PUBLIC_API_CACHEKV namespacePayload cache with marker invalidation
HYPERDRIVEHyperdrivea81d477ff9264805989f5a72f0354ee8 (shared, all workers)
public-api-invalidationQueue consumerbatch 10 / timeout 30 s / retries 3
API_ANALYTICSAnalytics EngineDataset api_requestsops-only, NOT consumed by analytics-rollup
PUBLIC_MEDIA_URLPUBLIC_DOCS_URLVarsPublic hostnames used to build payload links
API_INVALIDATION_SECRETSecretAuthenticates POST /_internal/invalidate (same value in Pages env vars)

Ops Logging (api_requests WAE dataset)

Every response on both hosts logs one datapoint, wrapped so logging can never affect a response:

PositionValue
index1Host kind: api | embed
blob1Host kind (again)
blob2Route pattern (e.g. /v1/podcasts/:slug) — never the raw path
blob3HTTP status
blob4Cache state (X-Cache header value)
blob5Colo
double11

No IP, no UA — no IP_HASH_SECRET needed. Unlike media_requests/feed_requests, this dataset has no blob-position contract with analytics-rollup; it exists purely for operational queries.

Player Build Pipeline

The player source lives in the main app at src/lib/components/embed-player/ (PlayerCore.svelte + the ShowfmPlayer.svelte custom-element wrapper + dependency-free contrast.ts). vite.player.config.ts at the repo root compiles it with customElement: true into workers/public-api/assets/player/v1.js. The dashboard preview and listen episode page import PlayerCore directly through the normal app build — same source, no custom-element runtime.

The bundle registers TWO element names: <showfm-player> (current, via the component's <svelte:options> tag) and <podcasterplus-player> (the pre-rebrand name, registered in element.ts as a subclass alias). The legacy registration is load-bearing forever: embed snippets are copy-pasted into third-party pages nobody can edit, and the Epic 16 rename briefly dropped it — every pre-flip embed rendered only its fallback link until the alias shipped (2026-08-28). A source-contract test (src/lib/components/embed-player/__tests__/element.test.ts) trips if the alias is removed.

Breaking player changes ship as /player/v2.js; v1.js receives compatible updates in place.

Deployment

bash
cd workers/public-api
pnpm run deploy   # runs the root `pnpm run build:player` first, then wrangler deploy

Every deploy builds the player bundle first: the wrangler.toml [build] custom build (pnpm run build:player, cwd repo root) runs on EVERY wrangler deploy, bare or scripted, production or --env staging (#297 item 1 closed the bundle-less-deploy hole that served an HTML 404 for /player/v1.js, which browsers refuse under nosniff). The package deploy scripts still build explicitly, which is a harmless double build. Both deploy workflows also smoke-check /player/v1.js for 200 + a javascript content type after deploying, and the /player/* asset-miss 404 is no-store so a zone cache rule can never pin it.

bash
# Secrets
npx wrangler secret put API_INVALIDATION_SECRET   # same value goes into Pages env vars

# One-time infrastructure (before first deploy)
npx wrangler queues create public-api-invalidation
npx wrangler kv namespace create PUBLIC_API_CACHE  # id into wrangler.toml

# Logs
npx wrangler tail

First deploy auto-creates the two custom domains/DNS. The edge WAF should extend the browser-integrity-check skip rule to api.podcasterplus.com (oEmbed is fetched by WordPress servers with non-browser UAs — same breakage class as the RSS-validator blocker) and add fair-use rate rules on both hostnames.

Internal documentation - Not for public distribution