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+jsonwith a machinecode; success is the resource JSON directly (no{ok}wrapper). Wire DTOs live insrc/lib/api/schemas.ts. - Demo contract: credential-less keyless GETs serve labeled demo data (
"mode": "demo"+x-p1-mode: demoheader); any presented credential and every mutation → honest503. - 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.yamlcovers v1 (thev1tag); 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'tapplication/jsonis parsed as form data./api/onboardingis JSON-only — it callsreq.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/bookingssends 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-Byis 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/cronwith noCRON_SECRETauthorizes 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 setHttpOnly; SameSite=Lax, plusSecurein 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_JWKSenv 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-Lengthover 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:
- An agent calls
createApproval(...)→ apendingapprovals row with a plain-language summary and apayload_hash(SHA-256 over key-sorted JSON). - The client decides via
POST /api/approvals. An approval past its expiry window is rejected at decision time (WSTG-BUSL-01): the row flips toexpiredand no token is minted — expiry is not deferred to consume time. - 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. - 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.tsheader for the rule. - LLM calls record
llm_usageevents ({task, model, token counts} only) via AsyncLocalStorage context — the source of truth for the 1.5× billable preview ingetUsageSummary. - 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) |