Public API Conventions
The public API (api.podcasterplus.com, implemented in workers/public-api) is the first of several public surfaces. These conventions are the contract every future public endpoint follows — v1 established them, and consistency is the product. If a new endpoint needs to deviate, that's a design discussion, not a local decision.
Reference implementation: workers/public-api/src/api/app.ts (routing + helpers), src/types/env.ts (payload contract), src/lib/ (validation, cursor, ETag, oEmbed).
Versioning
- URL path versioning: every endpoint lives under
/v1/…. - Additive changes stay in v1: new fields, new endpoints, new optional query params — no notice required. Consumers are told to tolerate unknown fields.
- Breaking changes require
/v2with ≥ 12 months of parallel v1 support and a deprecation timeline documented in the public changelog before anything is switched off. Breaking = renaming/removing a field, changing a type, changing semantics. - The embeddable player versions independently on its asset path:
/player/v1.jsreceives compatible updates in place; breaking player changes ship as/player/v2.js.
The payload interfaces in workers/public-api/src/types/env.ts are the v1 contract. Extend them additively; never rename or remove a field within v1.
Response envelope
// Single resource
{ "data": { … } }
// List
{ "data": [ … ], "pagination": { "next_cursor": "opaque-or-null" } }
// Error
{ "error": { "code": "machine_code", "message": "Human readable." } }- Field naming:
snake_case, matching the DB/internal API. - Timestamps: ISO 8601 UTC strings. postgres.js returns
timestamptzasDate— always normalise (toIso()) before output. - Descriptions are plain text only. User descriptions are HTML in the DB; shipping sanitised HTML cross-origin is an XSS liability. Strip tags server-side (
toPlainText). Adescription_htmlfield can be added later as an additive change if ever justified. - Correct HTTP status is always set; the body code and the status must agree.
Error codes
Stable machine codes — consumers match on error.code, so codes are contract:
| HTTP | Code | Use |
|---|---|---|
| 400 | invalid_request | Input failed validation (bad slug/UUID/cursor/param) — before any DB hit |
| 404 | not_found | Unknown or not publicly visible (never distinguish the two) |
| 429 | rate_limited | Edge fair-use limiting |
| 500 | internal_error | Unexpected failure; generic message, details to logs only |
| 501 | not_implemented | Recognised-but-unsupported variant (e.g. oEmbed format=xml) |
| 503 | service_unavailable | Dependency unavailable (e.g. Hyperdrive binding absent) — fail closed |
New codes may be added (additive); existing codes never change meaning. Error responses are no-store, except 404s, which are cached public, max-age=60 — public content probes are hot and cacheable.
Pagination
- Opaque keyset cursor: base64url of
`${published_at}|${id}`(or the equivalent sort key for a new collection). Decoding validates both parts; a tampered cursor →invalid_request, never a 500. - SQL:
WHERE (published_at, id) < ($cursor…) ORDER BY published_at DESC, id DESC, fetchinglimit + 1rows to derivenext_cursor. limitdefault 20, max 50; integer values outside the range are clamped, non-integers →invalid_request.next_cursor: nullsignals the last page.
Never expose offset pagination on a public endpoint — keyset is stable under concurrent publishes and doesn't invite deep scans.
Caching
- Success headers:
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600+ strongETag(hash of the payload JSON) +If-None-Match→ 304. IncludeX-Cache: HIT|MISSfor observability. - KV payload cache with marker-based invalidation (1 h entry TTL) behind the response cache; see the worker doc for the marker-TTL reasoning.
- Anything cached in KV must be invalidatable through the existing fan-out (
public-api-invalidationqueue +/_internal/invalidate); wire new cache keys intoinvalidatePodcast()or store the owningpodcastSlugso the marker covers them.
CORS & security headers
Access-Control-Allow-Origin: *, methodsGET, HEAD, OPTIONS, no credentials,Access-Control-Max-Age: 86400. The wildcard is correct and safe precisely because the API is keyless and credential-free — revisit the moment an endpoint carries credentials.X-Content-Type-Options: nosniffon every response; JSON asapplication/json; charset=utf-8.- Public routes are
GET/HEAD/OPTIONSonly. Any mutation surface is internal, secret-bearing, and never documented publicly. - No cookies on
api.orembed., ever.
Input validation before DB
Validate everything before any database round-trip — failures cost no I/O:
- Slugs:
^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]?$(same rule as rss-feed). - IDs: strict UUID regex.
- Cursors: decode + validate both parts.
- Enum-ish params (theme, size, format): allowlist; fall back to defaults or return
invalid_request/not_implementedas appropriate.
Invalid input → 400. Valid-but-unknown → 404. Malicious ids must produce 404, never 500 (house test standard).
Endpoints that accept URLs (oEmbed) parse, never fetch — hostname exact-match against our own configured hosts. No SSRF surface.
No private columns
- Explicit column lists in every query. No
SELECT *. The row interfaces intypes/env.tsdocument exactly what may be selected. owner_email,email_visible_until, verification tokens, billing ids, and any other private column are never selected into this worker — not even into intermediate rows. The RSS feed's timed email reveal deliberately does not carry over.- Interpolated values that land in generated HTML (oEmbed
html, iframe shells) always pass throughescapeHtml. - Tests must assert forbidden columns are absent from payloads (see the payload-mapper tests).
Auth: keyless now, keyed later
- v1 read endpoints are keyless. No auth logic exists in the read path.
- The scheme
Authorization: Bearer pp_live_…/pp_test_…is reserved (documented publicly) for future private/write endpoints. Until then, credentials sent to read endpoints are silently ignored — never an error (so future-proofed clients work today). - The first keyed endpoint brings the
api_keystable (hashed secrets, scopes,revoked_at— report-share-token pattern) and arequireApiKey()middleware; do not improvise auth before that lands. - Rate limiting is fair-use at the edge (WAF rules on
api.+embed.), not in-worker. Cache-first design absorbs the hot path. Revisit when API keys arrive.
The visibility contract
The public API must never expose more than the RSS feed already exposes. Every query mirrors the SQL filters used by workers/rss-feed/src/db/client.ts and the listen pages:
-- podcasts
hosting_type = 'podcasterplus' AND status IN ('active', 'paused') AND is_active = true
-- episodes
status = 'published' AND published_at <= now() AND is_blocked IS NOT TRUERules that follow:
- Every query applies the full filter set — including indirect lookups. A bare episode UUID joins the parent podcast with the complete podcast filters; child resources of a future endpoint must do the same. Nothing may resolve past its parent's gating.
- Unlike RSS,
pending_deletionshows are not served (no migration-grace need outside the feed; matches the listen pages). - If a future feature adds a visibility dimension (private feeds, member-only episodes), the public API defaults to excluded until an explicit decision includes it.
- Changes to the RSS/listen visibility SQL must be swept into this worker in lockstep — grep the filter concept, not the phrasing.
Checklist for a new public endpoint
- [ ] Route under
/v1(or the current version),GET/HEADonly, mounted inworkers/public-api/src/api/app.ts - [ ] Envelope + stable error codes; 404 for invisible resources
- [ ] Input validated before any DB hit; malicious input → 4xx never 5xx
- [ ] Visibility SQL mirrored in full, including joins for indirect lookups
- [ ] Explicit column list; no private columns; payload interface added to
types/env.ts(additive) - [ ] Plain-text description fields;
escapeHtmlon anything landing in HTML - [ ]
ETag+ cache headers; KV caching wired into the invalidation fan-out if used - [ ] Ops logging via the shared
logRequest(route pattern, not raw path) - [ ] Worker unit tests: validation, payload mapping (forbidden columns absent), ETag/304, CORS
- [ ] Public docs updated:
docs-public/src/developers/api-reference.md+ changelog entry inversioning.md
Related
- Public API Worker — the reference implementation
- RSS Feed Worker — origin of the visibility SQL and cache patterns
- Public docs: docs.podcasterplus.com/developers
- Implementation plan:
docs/planning/plans/2026-07-06-embeddable-player-public-api.md(§A2)