Skip to content

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 /v2 with ≥ 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.js receives 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

json
// 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 timestamptz as Date — 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). A description_html field 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:

HTTPCodeUse
400invalid_requestInput failed validation (bad slug/UUID/cursor/param) — before any DB hit
404not_foundUnknown or not publicly visible (never distinguish the two)
429rate_limitedEdge fair-use limiting
500internal_errorUnexpected failure; generic message, details to logs only
501not_implementedRecognised-but-unsupported variant (e.g. oEmbed format=xml)
503service_unavailableDependency 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, fetching limit + 1 rows to derive next_cursor.
  • limit default 20, max 50; integer values outside the range are clamped, non-integers → invalid_request.
  • next_cursor: null signals 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 + strong ETag (hash of the payload JSON) + If-None-Match → 304. Include X-Cache: HIT|MISS for 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-invalidation queue + /_internal/invalidate); wire new cache keys into invalidatePodcast() or store the owning podcastSlug so the marker covers them.

CORS & security headers

  • Access-Control-Allow-Origin: *, methods GET, 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: nosniff on every response; JSON as application/json; charset=utf-8.
  • Public routes are GET/HEAD/OPTIONS only. Any mutation surface is internal, secret-bearing, and never documented publicly.
  • No cookies on api. or embed., 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_implemented as 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 in types/env.ts document 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 through escapeHtml.
  • 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_keys table (hashed secrets, scopes, revoked_at — report-share-token pattern) and a requireApiKey() 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:

sql
-- 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 TRUE

Rules that follow:

  1. 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.
  2. Unlike RSS, pending_deletion shows are not served (no migration-grace need outside the feed; matches the listen pages).
  3. 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.
  4. 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/HEAD only, mounted in workers/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; escapeHtml on 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 in versioning.md

Internal documentation - Not for public distribution