Project One

API Conventions

Everything every endpoint shares, written once. Resource pages link here instead of repeating it.

Source of truth: src/lib/rate-limit.ts, lib/runtime.ts, lib/supabase/context.ts, proxy.ts, src/lib/approval-gate/index.ts, src/lib/telemetry/index.ts, src/lib/usage/meter.ts. When this page and the code disagree, the code wins — then fix this page.


0. The versioned surface: /api/v1 (added 2026-07-22)

Everything below documents the console surface (/api/* legacy routes) — human-triggered, {ok} envelopes, demo-tolerant. The programmatic surface is /api/v1/* and has its own, stricter contracts — full reference: resources/platform-api-v1.md (module + design record: src/lib/api/README.md, docs/discovery/2026-07-22-platform-api.md). The one-paragraph version:

  • Errors are RFC 9457 application/problem+json with a machine code; success is the resource JSON directly (no {ok} wrapper). Wire DTOs live in src/lib/api/schemas.ts.
  • Demo contract: credential-less keyless GETs serve labeled demo data ("mode": "demo" + x-p1-mode: demo header); any presented credential and every mutation → honest 503.
  • Shared guard stack (IP limit → same-origin → body cap → auth → per-tenant limit); no CORS by design.
  • Endpoints today: GET /api/v1/status | /approvals | /creatives | /videos | /performance | /positioning | /angles | /scripts | /landing-page | /brand; POST /api/v1/approvals/{id}/decision | /creatives/{id}/decision | /creatives/{id}/animate | /approvals/pause-request | /approvals/budget-request; PATCH /api/v1/angles/{id}.
  • openapi.yaml covers v1 (the v1 tag); v1 paths are part of the definition of done for any v1 endpoint change, same as the console surface.

1. Base URL & transport

  • All endpoints are same-origin routes of the Next.js app (/api/*, plus a few page-level routes documented in public-pages and auth).
  • JSON in / JSON out unless a page says otherwise. Public form endpoints (/api/leads, /api/interest, /api/bookings, /api/auth/login) also accept form posts (application/x-www-form-urlencoded / multipart/form-data); anything that isn't application/json is parsed as form data. /api/onboarding is JSON-only — it calls req.json() with no form fallback, so a form-encoded body is silently read as empty and answers 400 (it was previously listed here in error).
  • Only /api/bookings sends CORS headers (its form lives on published client pages that may sit on another origin). Every other route is same-origin.
  • Every response carries the security headers set in next.config.mjs (issue #114): HSTS, Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self', X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (camera/mic/geolocation/topics off); X-Powered-By is removed. None of these change request semantics — listed so header-diffing clients aren't surprised.

2. Operating modes: demo vs live

The platform runs in one of two modes, decided per capability by environment variables (lib/runtime.ts):

Predicate True when Unlocks
isPlatformConfigured() (NEXT_PUBLIC_SUPABASE_URL ?? SUPABASE_URL) and SUPABASE_SECRET_KEY set Persistence, sessions, approvals, pipeline
isLLMConfigured() ANTHROPIC_API_KEY set Real model calls (Conductor chat, generation agents)

The pattern (uniform across ~15 routes): live logic sits behind a guarded dynamic import; if the predicate is false (keyless), the route serves a deterministic demo response of the same JSON shape, tagged "mode": "demo". Flipping to live is a config change, not an API change.

What this means for callers:

  • Always check for "mode": "demo" on success responses. In demo mode nothing was persisted, no email was sent, no model was called.
  • Live failures are never masked (updated 2026-07-22, "API slice 0"): when the platform is configured and the live handler throws, EVERY route answers an honest 500 { "ok": false, "error": ... } — including the acknowledgment paths (leads, interest, bookings, brand-assets, approvals) that previously fell back to a demo-success ack. Demo responses now mean exactly one thing: the platform is keyless. Decision paths additionally fail closed on input (approvals reject anything but a literal "approve"/"reject"; /api/cron with no CRON_SECRET authorizes nobody).

3. Authentication

Three tiers. Each resource page states which one applies.

Tier Mechanism Used by
Public None. Body carries instanceId where relevant; abuse is limited by rate limits + validation + one-time tokens. leads, interest, bookings (GET/POST), unsubscribe, vsl, public pages, onboarding (it CREATES the account — there is no session to authenticate; it runs on the service client and is guarded by rate limits + validation)
Session cookie Supabase auth cookies (sb-*), set by login/magic-link. proxy.ts rotates refresh tokens on session-bearing routes; handlers verify the JWT against the project JWKS via getSupabaseContext({auth:'user'}) and get two clients: supabase (RLS-scoped) and supabaseAdmin (service-role — every query scoped by instance_id at the call site). Failures → 401. conductor, approvals, creatives, videos, media, brand-assets, bookings/mark
Shared secret Authorization: Bearer <CRON_SECRET>, constant-time compare. Unset secret = nobody authorized. cron

Notes:

  • Session cookies (sb-*) are set HttpOnly; SameSite=Lax, plus Secure in production (WSTG-SESS-02) — uniformly across login, server components, and the middleware refresh.
  • Session verification is memoized per request (one JWKS verify per navigation), and the JWKS itself is fetched once per process (or read from the SUPABASE_JWKS env var when set).
  • proxy.ts (Next's middleware convention) only runs on session-bearing surfaces — public APIs and marketing pages are deliberately excluded so the CDN can serve them without a function invocation.

4. Rate limiting

checkRateLimit(key, {limit, windowMs}) — sliding window, keyed "<route>:<clientIp>" where the client IP comes from x-nf-client-connection-ip (Netlify), else the first x-forwarded-for hop, else "unknown".

Over the limit → 429 with header retry-after: 60 and body:

{ "ok": false, "error": "Too many requests — give it a minute and try again." }

Current rules (per client IP unless noted):

Route Limit Window
POST /api/auth/login 5 5 min
POST /api/onboarding 5 10 min
POST /api/interest 10 10 min
POST /api/leads 20 1 min
POST /api/conductor 20 1 min
POST /api/media 20 1 min
POST /api/brand-assets 10 1 min
POST /api/videos 10 1 min (+ per-instance 10/min after auth)
POST /api/approvals 30 1 min
POST /api/creatives 30 1 min
POST /api/bookings/mark 30 1 min
GET /api/vsl 60 1 min
GET/POST /api/unsubscribe 30 1 min
GET /api/bookings (slots) 60 (+ per-instance 40/min) 1 min
POST /api/bookings 10 (+ per-instance 15/min) 1 min
GET /api/bookings/manage 30 1 min
POST /api/bookings/manage 15 1 min
GET /p/* 120 1 min

Honest scope: the limiter is per-process memory. On serverless, each warm instance counts separately — treat it as abuse friction, not a security boundary. Real protections are validation, RLS, HMAC tokens, and anti-enumeration.

5. Body-size limits

Routes that parse bodies call enforceBodyLimit(req, maxBytes) before reading the body:

  • Missing/unparseable Content-Length → 411 { "ok": false, "error": "A Content-Length header is required." } (chunked transfer would bypass a header check, so a measurable length is required — always send Content-Length).
  • Content-Length over the cap → 413 { "ok": false, "error": "Request body too large." }

Caps: 1 MB default (conductor, leads, interest, videos, bookings, approvals, creatives, bookings/mark); 6 MB where base64 files ride in the body (media, onboarding, brand-assets).

6. Error & response envelopes

The dominant convention is { "ok": boolean, ... }:

Situation Status Shape
Missing/invalid fields 400 { "ok": false, "missing": ["field", ...] } or { "ok": false, "error": "<instruction>" }
Unknown resource 404 { "ok": false, "reason": "<why>" } (approval-gate vocabulary uses reason)
Rate limited 429 shared envelope above
Body guards 411 / 413 shared envelopes above
Live write failed while configured 500 { "ok": false, "error": "<honest message>" } — never masked as demo success
Success 200 { "ok": true, ... } (+ "mode": "demo" in demo)

Known exceptions (documented on their pages): /api/conductor uses { reply, mode } / { error } without ok; /api/auth/login answers with 303 redirects, never JSON; /api/cron returns plain text on auth failure.

7. The approval gate (why mutations feel indirect)

Real-world actions (publish, launch, budget, account creation, brand/positioning/angle activation) never happen directly from a request. The flow:

  1. An agent calls createApproval(...) → a pending approvals row with a plain-language summary and a payload_hash (SHA-256 over key-sorted JSON).
  2. The client decides via POST /api/approvals. An approval past its expiry window is rejected at decision time (WSTG-BUSL-01): the row flips to expired and no token is minted — expiry is not deferred to consume time.
  3. On approve, the server mints a one-time capability token (32 random bytes; only its HMAC is stored — secret APPROVAL_TOKEN_SECRET, server-held). The token never reaches the browser.
  4. The executing agent calls verifyAndConsume(...), which checks — in order — existence, instance, status, expiry (7-day default), token HMAC, payload hash, and action type before atomically consuming. What was approved is exactly what runs; it cannot quietly become something else.

Failure vocabulary (reason strings): approval not found, instance mismatch, status is <s>, expired, bad token, payload mismatch, action mismatch: …, already consumed.

8. Telemetry & metering (what gets logged about your calls)

  • Features only, never content: telemetry rows carry booleans/counts/enum labels — no message text, no creative copy, no emails. See src/lib/telemetry/index.ts header for the rule.
  • LLM calls record llm_usage events ({task, model, token counts} only) via AsyncLocalStorage context — the source of truth for the 1.5× billable preview in getUsageSummary.
  • A per-instance rolling-24h token budget (LLM_DAILY_TOKEN_BUDGET, off unless set) can park generation; the pipeline keeps observing but won't call the model until the window clears.

9. Environment variables (complete list)

Variable Purpose Default / unset behavior
ANTHROPIC_API_KEY Live LLM calls unset → demo responder
ANTHROPIC_MODEL_GENERATION / _ANALYSIS / _CHAT Per-role model routing claude-opus-4-8
NEXT_PUBLIC_SUPABASE_URL / SUPABASE_URL Supabase project URL (interchangeable) unset → demo mode
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY / SUPABASE_PUBLISHABLE_KEY Browser/session key unset → no sessions
SUPABASE_SECRET_KEY Server secret key (with URL ⇒ isPlatformConfigured) unset → demo mode
SUPABASE_JWKS Pinned JWKS JSON; skips the well-known fetch unset → fetch once per process
APPROVAL_TOKEN_SECRET HMAC key for approval tokens required for the gate; requireSecret rejects short/placeholder values
UNSUBSCRIBE_TOKEN_SECRET HMAC key for unsubscribe + booking-manage tokens unset → emails go out without those links
CRON_SECRET Bearer guard on /api/cron unset → nobody authorized
PUBLIC_BASE_URL Canonical browser-facing origin (links in emails, sitemap) localhost / request origin fallbacks
MODEL_PRICE_JSON Overrides the LLM billing price map built-in defaults
LLM_DAILY_TOKEN_BUDGET Per-instance daily token cap unset → no cap
GEMINI_API_KEY Video generation provider (Gemini/Veo) — the gate unset → video generation stays pending, UI shows the demo clip
VIDEO_MODEL Gemini/Veo model id for the submit path veo-3.1-generate-preview
VIDEO_RESOLUTION 720p | 1080p (same price on standard tier) 720p
VIDEO_DAILY_CLIP_CAP Per-instance daily video-submit ceiling (row-counted) 10
VIDEO_PRICE_JSON Overrides the model→USD/second video price preview built-in map
BILLING_MARKUP Client markup over raw cost in the billing preview 1.5
ANTHROPIC_TIMEOUT_MS Per-request timeout for model calls 120000 (120s)
ANTHROPIC_MAX_RETRIES SDK retries per model call — note the worst case is timeout × (retries + 1), which must stay inside the cron's 300s budget 2
CRON_LOOP_CONCURRENCY Instances processed in parallel per tick 4
CRON_MAX_INSTANCES_PER_TICK Instances processed per tick, oldest first — see #169: without a cursor, tenants beyond this many are not reached 100

Video generation is a distinct backend with its own provider integration — see video-generation-provider.md for the full Gemini/Veo contract, lifecycle, and VERIFY-AT-LIVE caveats.

(requireSecret floor: secrets must be ≥16 chars and not match placeholder patterns — deploys with changeme fail loudly.)

10. Idempotency cheat-sheet

Operation Idempotent? Mechanism
Lead capture Yes upsert on (instance_id, email), backed by contacts_instance_email_uq (0044)
Interest registration Yes upsert on email
Nurture enrollment Yes partial unique index, active-only
Metric snapshots Yes daily upsert key
Approval decide/consume Single-use status-guarded updates, one-time token
Pause/budget requests (v1) Yes per payload payload-hash dedupe + per-action partial unique index (0040/0041); changed payload supersedes the pending row
VSL confirm Yes single-active partial index; 23505 absorbed
Booking outcome (mark) Yes no-op when already in the requested state
Cron tick Yes per tick bounded scans, claim discipline (video submits are CAS-claimed)