Project One

Platform API v1 (/api/v1/*)

The versioned, programmatic, tenant-isolated surface — built for machines (integrators, AI agents, our own future tooling), where the legacy /api/* routes above are built for the console UI. Different contracts on purpose; don't carry console-surface assumptions over. Design record: docs/discovery/2026-07-22-platform-api.md · module: src/lib/api/README.md.

Endpoint Returns / Does Extra limits beyond the defaults
GET /api/v1/status Pipeline position (StatusDto) —
GET /api/v1/approvals Pending approvals (ApprovalsDto) — the single most important GET —
GET /api/v1/creatives Image ads with 1-hour preview links (CreativesDto) —
GET /api/v1/videos Videos + animate candidates (VideosDto) —
GET /api/v1/performance Funnel counts + diagnosis history (PerformanceDto) —
GET /api/v1/positioning Active/draft positioning document (PositioningDto) —
GET /api/v1/angles The full angle bank, raw vocabulary (AnglesDto) —
GET /api/v1/scripts Script rows + VSL for the chosen angle (ScriptsDto) —
GET /api/v1/landing-page The newest lead page + copy (LandingPageDto) —
GET /api/v1/brand Brand board + registered assets (BrandDto) —
POST /api/v1/approvals/{id}/decision Decide an approval (DecisionDto) —
POST /api/v1/creatives/{id}/decision Decide a creative (DecisionDto) —
POST /api/v1/creatives/{id}/animate Seed a video from an approved static (AnimateDto) 10/min per instance (the tightest gate)
PATCH /api/v1/angles/{id} Bank housekeeping: retire / re-bank an angle (AngleStatusDto) —
POST /api/v1/approvals/pause-request Request a campaign pause → pending approval (ApprovalRequestDto) —
POST /api/v1/approvals/budget-request Request a daily-budget change → pending approval (ApprovalRequestDto) —

Defaults for every endpoint: 60/min per IP per path (pre-auth) + 120/min per instance (post-auth, live only); mutations carry a 1 MB body cap. Versioning is URL-path (/v1), evolution is additive-only.


How v1 differs from the console surface

Console (/api/*) v1 (/api/v1/*)
Success shape { "ok": true, ... } The resource JSON directly — no ok wrapper; DTOs in src/lib/api/schemas.ts
Error shape { "ok": false, ... } variants RFC 9457 application/problem+json with a stable machine code
Keyless demo Demo acks on many mutations GETs serve labeled demo data; every mutation → honest 503; any presented credential → 503
CORS Only /api/bookings Deliberately none — bearer keys must not be usable from browsers; server-to-server only
Tenancy instanceId in the body (validated) Derived from the credential, never from client input

The problem+json envelope

Every error, uniformly:

{ "type": "about:blank", "title": "Too many requests", "status": 429, "code": "rate_limited", "detail": "Retry after 60s." }

Branch on code, never on title/detail (developer-facing prose, may change). The full catalog (additive-only once public):

code Status When
invalid_request 400 Malformed body / bad decision value / missing path id
unauthorized 401 Configured platform, no session cookie
forbidden 403 Cross-origin mutation, or no workspace provisioned for this account
not_found 404 Resource not visible (RLS — covers "not yours", anti-enumeration)
rate_limited 429 Any of the three windows; retry-after header carries seconds
length_required 411 Mutation without Content-Length — including bodyless POSTs like animate
conflict 409 Well-formed request refused by current state (live angle, state moved mid-request). Added slice 8 — endpoints that predate it keep their historical status for the same semantic (the creative decision's 404 already_live); additive-only forbids moving them
payload_too_large 413 Over 1 MB
platform_not_configured 503 Keyless platform: any mutation, or any presented credential
api_keys_not_available 501 Bearer key presented — format is live, verification isn't (see below)
insufficient_scope 403 Reserved — emitted once key scopes are enforced
internal_error 500 Anything unexpected — never masked as demo

Demo is signaled twice

Keyless credential-less GETs serve deterministic demo data with "mode": "demo" in the body and an x-p1-mode: demo response header. Live responses carry "mode": "live" and no header. A machine must never receive a fabricated ack: mutations and credentialed calls on a keyless platform get the honest 503.


Authentication (exactly one path works today)

You present Keyless platform Configured platform
Nothing GETs → demo data · mutations → 503 Session-cookie path (below)
Authorization: Bearer p1_live_… 503 platform_not_configured 501 api_keys_not_available
Any other Authorization value 503 501 (detail: Unrecognized credential.)
Supabase session cookie (sb-*) n/a (no sessions keyless) Live — JWKS-verified; 401 unauthorized if invalid

API keys — format shipped, verification pending. p1_live_ + 43 base62 chars (256-bit CSPRNG), SHA-256 at rest, plaintext shown exactly once, p1_live_…{last4} hint for dashboards. The api_keys table (migration 0036) carries instance_id (one tenant per key), scopes (default {read}; read / decide / media:write / admin — defined now, enforced when keys go live), last_used_at, revoked_at (no expiry column — keys revoke, they don't expire). There is no key-creation endpoint or UI yet; the key-authed query principal is an open decision needing a live Supabase project (src/lib/api/README.md). Session-cookie auth = all scopes.

Tenant resolution is deterministic: the instance comes from the credential — for sessions, the account's oldest instance (created_at ascending, limit 1) until explicit instance selection ships. Multi-instance accounts always operate on their first workspace. No workspace → 403 forbidden.

Mutations assert same-origin (WSTG-SESS-05): a present-and-mismatched Origin header → 403 forbidden (Cross-origin request rejected.). An absent Origin passes — server-to-server clients are unaffected; this is CSRF defense-in-depth for cookie-bearing browser calls, on top of SameSite=Lax.

Guard order (fixed, in wrapper.ts): IP limit → same-origin → body cap → auth → demo gate → per-tenant limit → handler. The tenant window is the real limiter; the IP window is pre-auth friction (per-process memory — see conventions §4's honest-scope note).


The ten reads

No query parameters, no pagination, no cache-control on any of them. Every response is validated through its zod DTO before it leaves — if live data drifts out of an enum, you get a 500, never a partial body.

Two vocabulary rules hold everywhere: DB check-constrained columns are closed enums on the wire (angle/script/page status, script type), while LLM-agent vocabularies stay open strings (archetype, script structure/format) — the current values are documented, but an agent-vocabulary addition must never turn every GET into a 500. Singleton resources signal "not drafted yet" with a null body (positioning: null, page: null, brand: null) — never placeholder prose; read /api/v1/status for where the pipeline actually is.

GET /api/v1/status → StatusDto

{ "mode": "live", "stage": "creative", "completed": ["intake", "positioning", "angles", "scripts"], "stages": ["intake", "positioning", "angles", "scripts", "creative", "page", "launch", "live"] }

stages is the full ordered vocabulary; stage is where the pipeline sits now. Demo always shows the fixed position above.

GET /api/v1/approvals → ApprovalsDto

{ "mode": "live", "approvals": [{ "id": "…", "action_type": "page_publish", "summary": "Publish the landing page …" }] }

Poll this, decide via the mutation below — that's the core integration loop. summary is free text for humans; action_type is the machine handle.

GET /api/v1/creatives → CreativesDto

{ "mode": "live", "creatives": [{ "id": "…", "hook": "Overlay text", "status": "approved", "preview_url": "https://…" }] }

status ∈ draft | approved | rejected | retired. preview_url is optional and short-lived (1-hour signed links) — refetch, never store. A live reader that isn't ready yields creatives: [] at 200, not an error.

GET /api/v1/videos → VideosDto

{ "mode": "live", "videos": [{ "id": "…", "hook": "…", "state": "needs_okay", "video_url": "https://…", "duration_seconds": 8 }], "candidates": [{ "id": "…", "hook": "…" }] }

state ∈ generating | needs_okay | approved | failed. candidates = approved statics eligible for animate (feed their id to the animate endpoint). video_url optional; duration_seconds defaults to 8.

GET /api/v1/performance → PerformanceDto

{ "mode": "live", "funnel": { "leads": 41, "calls_booked": 18, "shows": 14, "sales": 5 }, "history": [{ "date": "2026-07-20", "stage": "launch", "severity": "warn" }] }

funnel is nullable (no metric row yet = null, not zeros); history is the last 6 diagnosis records. Strict reads: unlike the console readers (which render a DB blip as an empty screen), these two queries throw on error → 500 — an empty result here really means "no data", and a 500 really means "retry". (Every v1 read is strict — the two that share a reader with the console, /creatives and /videos, opt in explicitly so the console keeps its forgiving default. It's called out here because the contrast with the console is starkest.)

GET /api/v1/positioning → PositioningDto

{ "mode": "live", "positioning": { "status": "active", "version": 2, "niche_statement": "…", "core_problem": "…", "contrarian_thesis": "…", "core_message": { "structured": "…", "spoken": "…" }, "ideal_client": { "description": "…", "urgent_problem": "…", "pain": "…", "already_tried": ["…"] }, "category_of_one": { "unique_mechanism_name": "The Real-Week Method", "unique_mechanism": "…", "proof": "…", "philosophy": "…", "identity": "coach" }, "tone_of_voice": { "register": "…", "signature_phrases": ["…"], "never_say": ["…"] }, "contradictions": [] } }

The active document wins; else the newest ready draft (one with a core message) as status:"draft"; else positioning: null. contrarian_thesis and category_of_one are nullable (older documents predate them). Deliberately excluded: problem_archetype and confidence_notes — internal platform/telemetry fields.

GET /api/v1/angles → AnglesDto

{ "mode": "live", "angles": [{ "id": "…", "name": "Bends, doesn’t break", "moment": "…", "one_line": "…", "archetype": "mechanism", "predicted_fit": 92, "status": "live", "positioning_version": 2 }] }

Ranked by predicted_fit (nullable — null means unranked, never a fake 0). status is the raw four-value vocabulary draft | live | in_bank | retired — the console's "bank" grouping is a UI merge that doesn't exist here. archetype is the raw snake_case key (today: line_in_the_sand | origin | objection | case_study_proof | mechanism | end_of_cycle_pain | fear_stakes | identity_belonging — open vocabulary). one_line is the sentence handed to the Script agent.

GET /api/v1/scripts → ScriptsDto

{ "mode": "live", "chosen_angle_id": "…", "vsl_video_live": false, "scripts": [{ "id": "…", "angle_id": "…", "type": "ad_script", "structure": "hook_truth_shift_cta", "format": "short_video", "status": "approved", "length_seconds": 25, "hook": "…", "body": "…", "cta": "…", "compliance_checked": true }] }

Rows are filtered to the chosen (live) angle when one exists. type discriminates ad_script | vsl | ad_copy — and structure, format, cta, body, length_seconds are null on the rows where the agent writes them null (every ad_copy row has null structure/format/cta; the vsl row has null format). Handle the nulls; they are the honest shape, not an error. vsl rows additionally carry sections (keyed big_promise → offer_cta — the Diagnostics vocabulary). compliance_checked is the machine fact behind the console's reassurance copy. vsl_video_live = an uploaded VSL video is active in the media library.

GET /api/v1/landing-page → LandingPageDto

{ "mode": "live", "page": { "id": "…", "status": "published", "version": 3, "url": "https://…", "angle_id": "…", "message_match": true, "tracking_installed": true, "form_targets": ["instance_storage"], "published_at": "2026-07-01T09:00:00Z", "copy": { "eyebrow": "…", "headline": "…", "subhead": "…", "vsl_caption": "…", "accelerators": ["…"], "book_now_closer": "…", "reassurance": "…", "testimonials": ["…"], "faqs": [{ "q": "…", "a": "…" }], "cta_label": "…", "form_fields": ["name", "email", "phone"] } } }

Newest lead page only (page: null before the first draft). url is null until published. message_match is tri-state: null = the check hasn't run yet, false = ran and failed (it must be true to publish). The rendered html is deliberately off the wire (heavy; additive later).

GET /api/v1/brand → BrandDto

{ "mode": "live", "brand": { "status": "active", "version": 1, "rationale": "…", "palette": { "primary": { "hex": "#234f62", "usage": "…" }, "secondary": {}, "accent": {}, "ink": {}, "ground": {} }, "typography": { "heading_feel": "…", "body_feel": "…" }, "logo": { "present": true, "clear_space_note": "…" }, "imagery": { "style": "…", "treatment": "…", "subjects": "…", "never": "…" }, "motifs": ["…"], "never_look_like": ["…"], "provenance": { "palette": { "source": "extracted", "confidence": "high", "note": "…" } } }, "assets": [{ "kind": "logo", "label": "wordmark.png" }] }

No storage paths, no signed URLs — logo.present is the fact; raw bucket paths would leak private-bucket layout and be useless anyway (signed *_url fields are a reserved additive slice riding the creatives pattern). provenance keeps the raw source/confidence enums plus the evidence note per field. assets lists what onboarding registered (kind ∈ logo | photo | url | meta_asset; label falls back to the URL) and is present even while brand is still null.


The six mutations

All return the DTO directly on success; the approval-gate capability token is structurally stripped and can never leak. Every id-carrying mutation asserts the target row belongs to the credential-derived instance — a row from a sibling instance you happen to own answers that endpoint's not-found dialect, indistinguishable from nonexistent (v1 mutates only what v1 reads; #116).

POST /api/v1/approvals/{id}/decision

Body: { "decision": "approve" | "reject" } — exact lowercase, fail-closed (anything else → 400 with the instruction in detail).

Outcome Response
Decided { "id": "…", "status": "approved" | "rejected" }
Not visible under RLS 404 not_found (No such approval.)
Decision saved, apply failed 500 internal_error, title Approval saved, apply failed — the decision persisted; retry shortly applies it
Already decided / expired 500 internal_error — not idempotent; expired approvals flip to expired at decision time, no token minted

POST /api/v1/creatives/{id}/decision

Same body contract. Friendlier repeat semantics than approvals: same decision twice = no-op success, approve↔reject flips allowed. A live creative refuses with 404 (already_live in detail — running ads change through the approval gate); not_found and update_failed also surface as 404 detail tokens.

POST /api/v1/creatives/{id}/animate

No request body — the id rides in the path. You must still send Content-Length: 0 (it's a POST; absent header → 411).

Outcome Response
Seeded { "id": "…", "animation": "queued" }
Already has a video { "id": "…", "animation": "already_queued" } — the one-video-per-source unique index makes retries/double-clicks safe
Not found / not a static / not approved 400 invalid_request (precondition string in detail — note not-found is 400 here, not 404)
Per-instance throttle (10/min) 429 rate_limited

Generation happens in the cron tick (budget-capped, GEMINI_API_KEY-gated); poll GET /api/v1/videos for state transitions, then approve the finished video via the creative decision endpoint.

PATCH /api/v1/angles/{id}

Body: { "status": "retired" | "in_bank" } — bank housekeeping only. Going live is deliberately unrepresentable here: activation belongs exclusively to the angle_choice approval (a PATCH that could activate would bypass the gate and break the one-live-angle assumption every downstream stage reads).

Outcome Response
Changed (or already there — idempotent no-op) { "id": "…", "status": "retired" | "in_bank" }
status is live/draft/anything else 400 invalid_request — the detail points you at the angle_choice approval
Not visible, or belongs to another of your instances 404 not_found — v1 acts only on the credential-derived instance
The angle is currently live 409 conflict — retiring the live angle would stall the pipeline; replace it via the angle flow first
State moved between your read and the write 409 conflict — a concurrent activation won the race; refetch and retry

The write is conditional on the status the request observed, so a concurrent angle_choice approval can never be silently overwritten.

POST /api/v1/approvals/pause-request · POST /api/v1/approvals/budget-request

Request-only endpoints — they create a pending approval, nothing more. The row shows up in GET /api/v1/approvals and is decided via the decision endpoint; and until the Meta Graph adapters are wired, a decided pause/budget change is recorded, not executed. The target (campaign / ad set) is resolved server-side from your instance — Meta object ids never come from the client.

  • pause-request: no body (send Content-Length: 0 — 411 otherwise). Targets the newest active launched campaign.
  • budget-request: { "daily_budget_cents": <integer 100..1000000> } ($1–$10,000/day), fail-closed.
Outcome Response
Created 201 { "id": "…", "action_type": "meta_pause" | "meta_budget", "status": "pending", "summary": "…", "expires_at": "…" }
Identical repeat (same target/amount, still pending) 200 with the same row — deduped, never a twin
Nothing launched to target 409 conflict — pause: no campaign yet; budget: no ad set yet. On today's live tenants this is the expected answer until meta_launch executes for real
Invalid budget body 400 invalid_request

One pending request per action per instance — enforced at the database (migration 0041's per-action partial unique index, scoped to meta_pause/meta_budget): a changed budget amount (or pause target) supersedes the previous pending request — the old row flips to expired so two amounts are never simultaneously decidable, even under concurrency (a conflicting concurrent insert loses the race and either returns the identical twin or supersedes the different-payload holder and retries). Past-expiry pending rows are swept the same way, and the new row is inserted before losers are expired — a failed request never destroys the previously valid pending one. payload, payload_hash, and Meta object ids never reach the wire.


Notes for callers

  • Read code, not prose. title/detail are developer-facing English and may change; code is the contract.
  • Check mode (or x-p1-mode) before trusting data — demo data is deterministic fixtures, not your tenant.
  • The decisions loop is the product: poll /api/v1/approvals → POST …/decision → the platform executes through the approval gate (conventions §7).
  • Approval decisions are terminal-state writes; treat a 500 on a repeat decide as "already done — verify via the GET", not as a retry loop.
  • Idempotency-Key is reserved for future money-moving endpoints; current mutations are naturally idempotent (or terminal) without it — the request endpoints add payload-hash dedupe on top (201 = created, 200 = you got the existing pending row).
  • Pause/budget are requests, not actions: approval records the decision; execution awaits the Meta Graph adapters. Poll GET /api/v1/approvals to see them, decide via the decision endpoint.

Source: app/api/v1/*/route.ts, src/lib/api/wrapper.ts, src/lib/api/context.ts, src/lib/api/problem.ts, src/lib/api/schemas.ts, src/lib/api/keys.ts, src/lib/live/angles.ts, src/lib/live/meta-requests.ts, supabase/migrations/0036_api_keys.sql + 0040_approvals_pending_dedupe.sql + 0041_approvals_one_pending_per_action.sql, supabase/migrations/0010_scripts.sql + 0017_scripts_structure.sql (script nullability), src/lib/api/README.md